How to pack ternary numbers in 8-bit bytes

(compilade.net)

58 points | by JoshTriplett 6 days ago

11 comments

  • JKCalhoun 19 minutes ago
    Despite my ignorance on what would be involved, I like to fantasize that companies like Apple (who own their own chip design and hardware) can put these ternary pack/unpack operations in hardware with a single instruction for each (and also instructions for performing the various matrix convolutions using ternary numbers) so that we can get on-device LLMs.

    I suppose with the recent quantized Bonsai models we're already seeing on-device LLMs for phones and the like… But I (again, forgive my ignorance) assume that there is an order of magnitude or more in performance sitting out there if we get custom hardware instructions.

  • lioeters 1 hour ago
    Amusing that the link to an article on ternary numbers was posted by Mr Triplett. (:

    The article is well-written and illustrated. The technique described is used in llama.cpp for running language models like BitNet b1.58 whose weights are stored as ternary types.

    > ..in which every single parameter (or weight) of the LLM is ternary {-1, 0, 1}

    > significantly more cost-effective in terms of latency, memory, throughput, and energy consumption

    The original paper on this technique was published in Feb 2024. (Also linked from the article)

    The Era of 1-bit LLMs: All Large Language Models are in 1.58 Bits - https://arxiv.org/abs/2402.17764

    However, since then there have only been a few other models using ternary weights. I get the impression that there are factors not considered in the paper which make it less practical than it seemed.

    • edflsafoiewq 1 hour ago
      PrismML released a ternary model recently. However AFAICT they don't use this packing, instead packing trits as 2 bits each (as least for the GGUF).

      Hy-MT2 also came out with a 1.25bit model using a technique called Sherry, where weights are trits with the additional constraint that exactly one trit in a group of four is 0. Four possible positions for the 0 times 2^3 possibility for the other three positions = 32 possibilities, so it fits in 5 bits exactly. You can also exploit the sign symmetry by factoring out a sign bit and you have sixteen possibilities for a group where the first non-zero trit is +1. Unpacking can then be done with a 16 bytes lookup table, which is small enough to do really fast with SIMD.

  • JoshTriplett 6 days ago
    It's impressive how close to optimal this is.

    You can beat the efficiency of 5 trits in 8 bits (1.6) with as few as 17 trits in 27 bits (~1.588), but once you account for rounding up to a whole number of bytes for practical reasons, then beating the efficiency requires going to at least 111 trits in 176 bits (~1.586), or perhaps more practically for fast unpacking, 161 trits in 256 bits (~1.59).

    At that level, even if you have, say, 27B trits, the more efficient encodings would save something like 38-45MB (theoretical limit ~48MB), likely at the cost of some slowdown.

  • JKCalhoun 26 minutes ago
    "I'll be calling a "ternary digit" a "trit", like a "binary digit" is called a "bit"."

    And missed it by one letter. <jk>

  • Diggsey 2 hours ago
    How does this packing/unpacking scheme compare to just using a lookup table?
    • akoboldfrying 1 hour ago
      A 256x3=768-byte lookup table will almost certainly be faster on a CPU. It will fit easily in L1 cache, and can extract 3 bytes at a time.

      Ironically, x86 has an instruction, XLATB, that does almost exactly this (looking up a byte in a 256-byte table) -- but using it is actually slower than using an equivalent MOVZX RAX,AL; MOV AL,[RBX+RAX] sequence on modern CPUs.

    • zephen 2 hours ago
      I was wondering the same thing.

      Obviously, it partly depends on the implementation machine, how big a hole the tables blow in your cache, how fast the multiplies are, etc.

      But it probably also hugely depends on the format that you want your trits in. If you use them unpacked, e.g. one trit per byte, then even if you're using tables, you still have to do a lot of manipulation (e.g. either shifting and oring, or having different tables, and a table lookup per trit and adding together to get the binary).

      • akoboldfrying 1 hour ago
        You can have a single 3x256=768-byte table -- just multiply the input byte by 3 to get the offset into the table, and read out the 3 trits (1 trit per byte) beginning at that offset.

        ETA: If you're prepared to waste a byte per entry so that entries are 4 bytes wide, on x86 you can use effective address calculation to do the multiplication for you, letting you decode 3 trits in 1 CPU instruction:

            MOV EAX,[RBX+RCX*4]
        • zephen 1 hour ago
          There are 5 trits.

          In any case, if there were 3 trits, I'm not sure what the practical difference between a single table you describe and 3 different 256 byte tables, again depending on the architecture.

          Yes a single table would have better cache effects for a single read, but presumably? you're doing a lot of reads in an unpacking phase.

  • jjgreen 5 hours ago
    • hahahaa 1 hour ago
      I want an LLM trained on dailywtf code
    • woadwarrior01 1 hour ago
      I've seen this pattern repeated in a number of large production codebases using optional booleans.
    • benj111 5 hours ago
      Off the top of my head. Compilers. You may know that a value has known 1s and 0s and unknowns. This would allow you to represent that for optimisation purposes.
      • Imustaskforhelp 3 hours ago
        Arturo[0] language supports true,false and maybe. I really liked that idea actually, worth mentioning here.

        so valid arturo code can be like (picked from their in-a-nutshell documentation)

        i1: true

        i2: false

        i3: maybe

        [0]: https://arturo-lang.io/documentation/in-a-nutshell

        • taneq 2 hours ago
          Everyone hates null but (in database land, and arguably in other programming) it just means “no data.”

          (As an aside, I found the characterisation of null as being a “billion dollar mistake” to be unfair. Those who don’t acknowledge null are doomed to reimplement it, probably poorly, or to have the unknowns in their logic remain unknown unknowns.)

          • mcherm 1 hour ago
            The "mistake" was not in providing a way to represent "no data", it was in providing no way to represent "this definitely HAS" data.

            Languages that correct the problem have two separate types (eg: Foo & Option<Foo>) meaning "definitely a Foo" and "a Foo but it might have no data". Java just has Foo, meaning "a Foo but it might have no data" but no way to represent "definitely a Foo".

    • mi_lk 5 hours ago
      Had a good chuckle
      • abcd_f 3 hours ago
        If it weren't for Bool as a name, that's just 0 for OK, non-zero for errors with 1 being general failure.
        • inigyou 52 minutes ago
          TDWTF code is usually hyperbolized. It might not have been called Bool.
  • evrimoztamur 2 hours ago
    Would having 16 bits to pack a tuple of ternary numbers have a potential to give even better efficiency?
    • quietbritishjim 1 hour ago
      No.

      The article suggests using 8 bits (=256 values) to pack 5 trits (=243 values).

      If you use 16 bits (=65536 values) then you can pack at most 10 whole trits (=59049 values). 11 trits (=177147 values) won't fit.

      The current top comment, by JoshTriplett, analyses what you would need to beat the efficiency.

  • kleton 5 hours ago
    Would this imply that the matrices should have dimensions of multiples of 40?
  • Razengan 1 hour ago
    I was actually mucking with ChatGPT about possible "post-binary" architectures.

    I ended up feeling that ternary is just icky, because the "middle value" doesn't have an "opposite"

    Maybe we should do what quaternions did to complex numbers and just jump from 2 to 4: Quaternary CPUs

    I couldn't understand half of the words it spat out but it turns out that using pairs of "quits" as the atomic computation unit to represent complex numbers and/or 2x2 matrices could be ideal for AI etc.

    • entrope 3 minutes ago
      Base 4 isn't substantially different from base 2 except in granularity.

      There's a somewhat famous result that base e gives the best expected density for representing numbers, but fractional (and especially irrational) bases are inconvenient for real-world quantities. So ternary is theoretically the best integer choice, but binary has practical advantages for electronic logic.

    • anagnost 1 hour ago
      I have the opposite feeling: balanced ternary naturally removes the ickiness of twos-complement arithmetic and the unbalanced MIN_INT.
  • Joker_vD 5 hours ago
    > Fixed point numbers to the rescue!

        > a diagram that shows that dividing 0x7F (127) by 243 and then multiplying by 256 results in 0x86 (134)
    
    > Tada!

    How... how does that help with anything?

    > Now digits can be easily extracted from the top two bits of the resulting 10-bit number when multiplying this 8-bit byte by 3.

    What? Why? How? This is supposed to be the most insightful part of the post, and it's literally just "Behold!" from that one proof of Pythagorean theorem. Could someone please elaborate it for a non-genius like me?

    • Tepix 4 hours ago
      If your pack_number function builds the number up, the standard way to break it down is by extracting the least significant digit first using modulo and division. To get something that works well with SIMD we need a different approach. Instead of extracting the least significant digit from the bottom of an integer, we extract the most significant digit from the top of a fraction.

      1. Convert to a fixed-point fraction: We scale our integer N into a fixed-point representation (e.g., using a 32-bit integer to represent the fraction). We do this by multiplying N by a precomputed reciprocal of 243.

      2. Multiply by the base: Multiply the fraction by 3.

      3. Extract: The integer portion of the result is your most significant trit.

      4. Mask: Keep only the fractional remainder, and repeat.

      The only operations here are multiplication, bitwise shift, and bitwise AND, i.e. perfectly suited for SIMD.

      (in step 1 we replace the division with a multiplication by using the reciprocal. SIMD uses fixed-point integer arithmetic, not floating-point decimals)

    • zokier 2 hours ago
      I think the key insight here is that 243 is 100000 in base3. So dividing by 243 essentially converts any 5 digit base 3 number to [0,1) interval. Multiplying by 256 converts it to [0,256) interval which conveniently fits into a byte.
      • marginalia_nu 2 hours ago
        It's kinda remarkable this works given how base 2 and base 3 are incredibly poorly aligned and make for a kind of worst case scenario when it comes to division (e.g. 1/3 = 1/2 - 1/4 + 1/8 - 1/16 + 1/32 ... and 1/2 = 1/3 + 1/9 + 1/27 + 1/81 + ...)
    • marginalia_nu 4 hours ago
      If it's any consolation, I spent like two years of my life immersed in this field[1] and can still recite powers of three in the same way most nerds can only tell you powers of two, yet I still can't follow this floating point black magic.

      [1] behold my misspent youth: https://tunguska.sf.net/

      • StilesCrisis 1 hour ago
        Fixed point! Not floating. That's the whole trick.
  • yoshitha2010 1 hour ago
    [flagged]