I am happy to announce that we open-sourced EmulatedBitIntegers.jl. The package allows defining arbitrary bit width integers like UInt3 or Int7 which behave mathematically as you would expect, by overflowing and underflowing like a native implementation would do.
Internally, the package stores the values in a storage type which is a regular (non-emulated) primitive bit integer like UInt8. By implementing the Julia Base methods, the user normally does not need to know this except for two things
Storage space might be larger than expected, e.g. if you have a struct with two UInt4 values it will be 2 bytes instead of a single byte. But see the announcement for PackedStructs.jl how to avoid this.
Using them can be a bit slower due to the overflow and underflow guarantee which needs additional instructions in general.
That being said the package is highly performance optimized and for all I know generates the same machine code as a future Julia-native LLVM-based approach will generate (at least with Julia 1.12 and 1.13; Julia 1.10 lacks some LLVM optimizations). With all the progresses in superscalar processor architectures we have seen in the last two decades, there is no performance difference between the emulated bit integers and the native bit integers more often than not.
Work is ongoing to support arbitrary bit integers directly in Julia, see e.g. Julia #45486 and Julia #61359. I would be glad if this work makes EmulatedBitIntegers.jl obsolete.
Thanks to the nice people in JuliaData, who kindly allowed me to move and maintain the code there.
The combination EmulatedBitIntegers.jl + PackedStructs.jl seems somewhat related to the type PackedVector from SmallCollections.jl. (Disclaimer: I’m the author.) For example
julia> v = PackedVector{UInt16,5,Int8}([1,9])
2-element PackedVector{UInt16, 5, Int8}:
1
9
defines a vector with 5-bit integers that are packed into an UInt16 (hence with maximal length 3) and appear to the outside as Int8. Besides vector operations like
julia> Int8(3) * v
2-element PackedVector{UInt16, 5, Int8}:
3
-5
(note the overflow) there are functions like push, filter or findfirst.
Maybe one can combine PackedVector with EmulatedBitIntegers.jl to declare an honest element type Int5 instead of the fake Int8.
Although I don’t think that I’ve seen Int7 in particular, these kinds of “weird” integers are somewhat common when dealing with certain protocols or with data-aware serialization. If you know your data distribution is heavily skewed, you can use small integers as codewords for the most common information, thereby saving bandwidth. It’s of course also common when interfacing with certain hardware, since they sometimes only have limited precision for their output registers.
Thanks, @matthias314, this is an interesting idea, as a PackedVector is the logical pendant to a PackedStruct. I haven’t looked into the code of PackedStructs, but there seems at least to be an overly restrictive type check:
I don’t know how the interface for a generic PackedStruct would look like. If you don’t want to restrict yourself to small collections, I think it would at least not contain the first parameter. It should also be able to auto-group, which PackedStruct from SmallCollections.jl already does, If I understood this correctly.
In addition to @Sukera’s answer, I’d say these kind of types can be useful whenever you consider using something else than Int, so e.g. when you
want to make use of the modulo behavior, e.g. the index of a ring buffer
want to limit the allowed values with the type instead of with additional constructor constraint in immutable structs and/or with additional setindex! constraints in mutable structs
want to accelerate your code by optimizing caching together with e.g. PackedStructs
want to ensure correctness with the type system without exploding the sizes, by e.g. widen by a single bit when doing addition instead of doubling the bit size
Thanks for the report, @matthias314! I fixed the problem. It turned out, that here we could get even faster code, because we never need to throw and therefore even in the good case we can skip the branch.
I probably do not understand the package well enough, but isn’t it at least by default quite wasteful for being a small collection? In this example 3 (practically 4) bytes would suffice, but 16 bytes are used: