Meta String: A more space-efficient string encoding than UTF-8 in Fury
fury.apache.org
fury.apache.org
It’s really hard to actually beat something like zstd with these ad-hoc compression since both the compressed size will be bigger than what zstd produces with a dictionary or on larger messages without a dictionary AND the speed of a round trip through zstd is likely faster.
For dictionary encoding, Fury used similar tech too. When a string first come up, we encoding it as binary, later writing in same object graph will write such string as an varint id.
Zstd can be used with Fury too. For multiple serialization across different objects. Fury don't have a context to compression, unless users enable fury meta share mode. If meta share mode not enabled, zstd can be used to compress data across multiple object graph serializaiton
You're not referring to the same dictionary that I am. Look at --train in [1].
If you have a training corpus of representative data, you can generate a dictionary that you preshare on both sides which will perform much better for very small binaries (including 200-1k bytes). It's the same kind of technique but zstd's mechanism is absolutely general whereas purpose-built non-entropy based dictionaries are more limited & less likely to outperform.
If you want maximum flexibility (i.e. you don't know the universe of representative messages ahead of time or you want maximum compression performance), you can gather this corpus transparently as messages are generated & then generate a dictionary & attach it as sideband metadata to a message. You'll probably need to defer the decoding if it references a dictionary not yet received (i.e. send delivers messages out-of-order from generation). There are other techniques you can apply, but the general rule is that your custom encoding scheme is unlikely to outperform zstd + a representative training corpus. If it does, you'd need to actually show this rather than try to argue from first principles.
[1] https://github.com/facebook/zstd/blob/dev/programs/zstd.1.md
That is a statement you can make, but you have to actually demonstrate this is true against zstd's --train mechanism [1] which generates a more optimized "small string" dictionary based on representative training data precisely for this use-case.
[1] https://github.com/facebook/zstd/blob/dev/programs/zstd.1.md
> those are used for different scenarios. zstd is used for data compression, the meta string encoding here is used for meta meta encoding
Not sure what this means. The motivating use-case described for the alternate string encoding is precisely compression.
zstd’s approach is fundamentally very different from a basic interning dictionary. Seriously, there’s been tons of research on this. It’s also interesting that Fury claims to be 0-copy and then does all these ad-hoc field compression which is the literal definition of not 0-copy (& yes, varint encoding is not 0-copy and neither is this custom string compression).
Anyway, unless you actually do a proper comparison against zstd with a trained dictionary against a representative corpus, we’re just going in circles with me saying “you need to evaluate your ad-hoc compression against zstd” and you trying to argue from first principles that the custom compression scheme wins.
The thing here are that all those RPC are stateless, so the context for compression are the one message itself. i.e. compress object like `Point(1,2)` only without priori knowledge. Users may use train static zstd. But Fury can't, Fury doesn't know which data will be serialized.
And meta string encoding are not statistical, it won't beats zstd. It's just for cases zstd is not suitable.
You can hardcode expected frequencies and throw arithmetic encoding at it and the average size will probably drop a meaningful amount.
And I can't easily find an example corpus, but the description of these strings sounds like they'd often have repetition, so another symbol to encode repetition and make this into a normal compression algorithm is probably worth it.
I wonder how many of these string start with org.apache
That's not really a rethink of string encoding, just using a different fixed encoding.
Given the usecase, not sure i understand why not just use static huffman.
Isn't that essentially what you've done to define your bespoke alphabets / partial encodings?
5 bits 0 0000-1111 acde fghi lmno rstu
7 bits 10 00000-11111 bjkp qvwx yzAB CDEF GHIK LMNO PRST UVWY
7 bits 110 0000-1111 JQXZ 0123 4567 89/.
8 bits 111 00000-11110 !@#$ $%^& *()_ {}[] `~+= |\"' ;:<> ,? (space at the end)
16 bits 11111111 00000000-11111111 plus UTF-8 1 to 256 unicode characters encoded as UTF-8
You could even include some bigrams in this sort of table.There's some code here that could maybe be used for that sort of static huffman tree:
https://github.com/ryancdotorg/lztp/blob/main/generate-seed....
Alternatively, have something like this:
00 000000-111111 1-64 characters, 5 bits each
01 000000-111111 1-64 characters, 6 bits each
10 000000-111111 1-64 characters, ~6.4 bits each (character set of 84 characters, every 5 packed into 32 bits)
11 000000-111111 1-64 characters, UTF-8
This is vaguely similar to how data is encoded for QR codes.As pointed out elsewhere, this will not outperform zstd with a dictionary trained on a corpus, but zstd would require pulling in a lot of code.
Doesn't QR use variable static encodings (alphabets)? e.g. mode 1 is numerics, mode 2 is uppercase alphanumeric, mode 3 is binary, and mode 4 is jis (japanese).
Your suggestions are great. I should clarify why dict encoding can't be used to recude cost.
As for performance, it won't be an issue. The string for meta encoding are limited, we can cache the encoded result. So it's not in critical path
--first byte------- --second byte---
7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0
bit --first-- --second--- --third--
The 5-bit character encoding was one of three character sets: Z-char 6789abcdef0123456789abcdef
current --------------------------
A0 abcdefghijklmnopqrstuvwxyz
A1 ABCDEFGHIJKLMNOPQRSTUVWXYZ
A2 ^0123456789.,!?_#'"/\-:()
--------------------------
Z-char 0 was space in all character sets, z-char 1 was newline, z-chars 2 and 3 switched to one of the other character sets depending on which one you were already in for the next character only, and z-chars 4 and 5 switched permanently, the "shift-lock".https://inform-fiction.org/zmachine/standards/z1point0/sect0...
Good, but that's a pretty common technique for cases where every bit counts.
Those strings are mostly ascii strings. In order to transfer between processes, we encode such strings using utf-8 encodings. Such encoding will take one byte for every char, which is not space efficient actually.
If we take a deeper look, we will found that most chars are lowercase chars, ., $ and _, which can be expressed in a much smaller range 0~32. But one byte can represent range 0~255, the significant bits are wasted, and this cost is not ignorable. In a dynamic serialization framework, such meta will take considerable cost compared to actual data.
So we proposed a new string encoding which we called meta string encoding in Fury. It will encode most chars using 5 bits instead of 8 bits in utf-8 encoding, which can bring 37.5% space cost savings compared to utf-8 encoding.
For string can't be represented by 5 bits, we also proposed encoding using 6 bits which can bring 25% space cost savings
By the way, the specification is highly confusing to interpret to say the least as well. For example, is the "ref(erence) meta" a separate section in the serialization format or not? The corresponding section never mentions which bytes are to be written, and reference flags apparently describe the state of each object, so it can't be in that section anyway. Providing at least a single worked example would have significantly reduced confusion.
(Due to the inability to fully comprehend the specification, I can't give a general criticism and/or advice for now. But it does seem to miss several lessons from existing serialization formats. For instance, a varint encoding based on a contiuation bit is actually the worst encoding for given distribution, even though it is too common! Consider an alternative like `1^n 0 bit^(7n+7)` which can determine the contiuation length from the first byte instead.)
On top of my head, a better approach could be to have some mechanism to establish mapping from these human-readable identifiers to numeric identifiers (protocol handshake or some other schema exchange), and then use those numeric identifiers in the actual high-volume messages.
edit: umm... seems like fury is doing something like that already https://fury.apache.org/docs/guide/java_object_graph_guide#m... so I am bit puzzled if this saving really makes meaningful difference?!
> I am bit puzzled if this saving really makes meaningful difference?!
They make somewhat misleading claim - "37.5% space efficient against UTF-8" - but that doesn't tell us how much gain it is on a typical protocol interaction, It could be 37.5% improvement on 0.1% of all data or on 50% of all data - depending on that, the overall effect could vary drastically. I guess if they are doing it, it makes some sense for them, but it's hard to figure out how much it saves in the big picture.
[1] https://en.wikipedia.org/wiki/Standard_Compression_Scheme_fo...
[2] https://en.wikipedia.org/wiki/Binary_Ordered_Compression_for...
Furthermore LZ constructions work off of redundancy, which is difficult to find in large amounts in such short strings. The overhead of framing will just increase the size of the payload after construction.
You could define just a static huffman tree and make that part of your spec.
The default Huffman table is not suited for this use case, and a Huffman table in general is a waste of space these days, but the overhead is very small and can be reduced to nothing if you know the original string length.
https://mikeash.com/pyblog/friday-qa-2015-07-31-tagged-point...
If the length is between 0 and 7, store the string as raw eight-bit characters.
If the length is 8 or 9, store the string in a six-bit encoding, using the alphabet "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX".*
If the length is 10 or 11, store the string in a five-bit encoding, using the alphabet "eilotrm.apdnsIc ufkMShjTRxgC4013"
---
How about 5 bits? This isn't totally ludicrous. There are probably a lot of strings which are just lowercase, for example. 5 bits gives 32 possible values. If you include the whole lowercase alphabet, there are 6 extra values, which you could allot to the more common uppercase letters, or some symbols, or digits, or some mix.
Related thread - https://lobste.rs/s/5417dx/storing_data_pointers#c_l4zfrv
It's interesting how network formats and in-memory formats kinda converge in design, because retrieving data from memory to CPU is so expensive now.
So not "in memory" (although obviously it is in memory) it's when the goal is to fit the totality of the string plus a tag into a register.
If they can't completely encode the 10-11 character value using the 5-bit charset it spills to UTF-16 on the heap.
> It's interesting how network formats and in-memory formats kinda converge in design, because retrieving data from memory to CPU is so expensive now.
Honestly Strings are probably one of the worst examples of this. Strings have like 50 different representations depending on what you're trying to do with them.
1. Wire format for storage and retrieval is generally UTF-8.
2. If you're trying to parse a string you generally want to expand it into code points.
3. If you're trying to sort or compare strings you generally want to expand that into a normalized form like NFC or NFD.
4. If you're trying to edit text, you generally want grapheme clusters.
5. If you're trying to present text, you generally want pixels.
The way to think about it is: "there is no such things as the length of a string without context." Strings are basically a bytecode format for representing concepts.
[1] https://developer.apple.com/documentation/foundation/nsstrin...
- if you want to send a bunch of small strings over the network, you can pick a variable length encoding even more compact than UTF-8
- if you want to pass and return a lot of strings as values, store them in data structures like a List<Str>, then you can pick a variable length encoding even more compact than UTF-8. So you can compute on the entire List<Str> without indirections for the common case of small strings, with good cache locality
---
That doesn't really contradict anything you said -- the tagged pointer is in some sense a "wire format", and then you have to do a conversion to actually do something useful with the string.
Although sometimes you don't need conversions either. For len() in bytes or code points and graphemes, which are ALL identical for ASCII, you can calculate it directly on the tagged pointer.
And you also have random access to bytes, code points, and graphemes in that special case. A lot of the "work" happened when deciding that this encoding is valid for a particular string, which is nice.
7-bit ASCII yeah, not 8-bit, which it appears are supported in tagged pointers per your original comment.
8-bit ASCII has fun characters like é (ASCII 130, 0x82) which may be 1 grapheme cluster -- but.
There's several ways to represent it in Unicode. You can do it as U+00E9 (NFC) or you can do it as U+0065 U+0301 (the combining acute accent, NFD).
This means it's a byte length of 1 in ASCII -- but several in UTF-8 (since UTF-8 only has direct compatibility with the 7-bit plane). And either 2 or 4 in UTF-16.
Also, it means this string has a Unicode code point length of 1 or 2 depending on context and normalization form.
Again, asking the context-free len() of a string is a meaningless question.
> And you also have random access to bytes, code points, and graphemes in that special case.
You should never make this assumption. You should treat strings as opaque data blobs.
Unicode is just a billion edge cases in a trench coat ;)