Hacker News

How to pack ternary numbers in 8-bit bytes

How to pack ternary numbers in 8-bit bytes with efficient SIMD-friendly unpacking

There are 3 possible values in a digit of a ternary number. 3 possible values, which could actually be anything. I've been recently nerd-sniped[^1] into trying to pack the ternary weights of BitNet b1.58 into something close to that theoretical ideal of log(3) / log(2) bits[^2] per ternary digit. I'll be calling a "ternary digit" a "trit", like a "binary digit" is called a "bit".

Block size

Since the goal of this is to allow fast parallel unpacking, blocks of trits can't be infinitely big. A small "block" size needs to be found, ideally one which is both efficient with information density and which is convenient on current hardware.

To find a good block size, we'll need to find a power of 3 for which the next power of 2 is very close.

trits 3^trits bits 2^bits bits per trit
1 3 2 4 2
2 9 4 16 2
3 27 5 32 1.666...
4 81 7 128 1.75
5 243 8 256 1.6

It's very fortunate that 5 trits fit quite tight into 8 bits at 1.6 bits per trit. When compared to perfect packing, this is 99.06% efficient.

1.6 bits per trit

The basic idea with this packing scheme is simply to make a number out of the ternary digits.

def pack_number(digits: list[int], base: int) -> int:
    number = 0
    for digit in digits:
        assert digit < base
        number = number * base
        number += digit
    return number

Then, each 8-bit byte can store a 5-digit ternary number, effectively representing a fixed-point number between 0 and 1 inclusive, where the byte can store 243 possible values. The least significant base-3 digit is stored first.

def pack_ternary(digits: list[int]) -> bytearray:
    assert len(digits) % 5 == 0  # padding isn't handled here
    n_bytes = len(digits) // 5
    packed = bytearray()
    for i in range(n_bytes):
        b = 0
        for j in range(5):
            digit = digits[5*i + j]
            digit = max(-1, min(digit, 1))  # clamp between -1 and 1
            digit += 1  # from -1, 0, 1 to 0, 1, 2
            b *= 3
            b += digit
        b = ((b * 256) + (243 - 1)) // 243
        packed.append(b)
    return packed

The relevant interesting line is this one:

b = ((b * 256) + (243 - 1)) // 243

It does what is depicted in the diagram above, but multiplication is done first because these are integer operations. Doing a ceiling division here is necessary to cancel the off-by-one error from truncating when extracting digits later.

Unpacking

To unpack without using the modulo operator:

def unpack_trits(packed: bytes) -> list[int]:
    trits: list[int] = []
    for byte in packed:
        b = byte
        for i in range(5):
            b = b * 3
            trit = b >> 8
            trits.append(trit - 1)  # 0, 1, 2 => -1, 0, 1
            b = b & 0xFF
    return trits

Verification

To convince myself that this works, I wrote a C program checking that this really is lossless:

#include <stdio.h>
#include <stdint.h>
#include <string.h>

int main(void) {
    char s1[6] = {0};
    char s2[6] = {0};
    for (uint8_t i = 0; i < 243; i++) {
        // Turn the low 8 bits into a base-3 number (least significant first)
        uint8_t n = i;
        for (int j = 0; j < 5; j++) {
            s1[j] = (n % 3) + '0';
            n /= 3;
        }
        // Turn that number into a fixed-point number smaller than 1
        uint8_t q = (((uint16_t) i) * 256 + (243 - 1)) / 243;
        // This extracts the most significant digit first
        for (int j = 0; j < 5; j++) {
            uint16_t m = q * 3;
            s2[4 - j] = (m >> 8) + '0';
            q = m & 0xFF;
        }
        printf("%s, %s: %s\n", s1, s2, strcmp(s1, s2) == 0 ? "\033[1;32mPASS\033[0m" : "\033[1;31mFAIL\033[0m");
    }
    return 0;
}

Compile and run with:

$ gcc ternary-packing.c -o ternary-packing
$ ./ternary-packing

And I'm getting PASS for each of the 243 ternary numbers which fit in 8 bits.

And this is the technique used in the ternary types in llama.cpp for TriLMs and BitNet b1.58, for which the pull request is https://github.com/ggml-org/llama.cpp/pull/8151, with SIMD implementations for both AVX2 and ARM NEON.

[^1]: obviously referring to https://xkcd.com/356/, but the initial motivation actually started from this review comment I posted on the initial BitNet b1.58 pull-request for llama.cpp

[^2]: log(3) / log(2) is also known as 1.584962500721156.

Comments

No comments yet. Start the discussion.