bijective-varint
Version:
Length prefixed varint encoding
358 lines (306 loc) • 12.8 kB
text/typescript
// This file contains routines to do length-prefixed varint encoding. I'd use LEB128 but this should
// optimize better because it plays better with branch predictor. (Well, although this isn't an
// optimized version).
//
// This uses a bijective base, where each number has exactly 1 canonical encoding.
// See https://news.ycombinator.com/item?id=11263378 for an explanation as to why.
//
// This format is extremely similar to how UTF8 works internally. Its almost certainly possible to
// reuse existing efficient UTF8 <-> UTF32 SIMD encoders and decoders to make this code faster,
// but frankly its not a priority right now.
//
// 0 - 2^7-1 encodes as `0b0xxx_xxxx`
// 2^7 - 2^14+2^7-1 encodes as `0b10xx_xxxx xxxx_xxxx`
// 2^14+2^7 - 2^21+2^14+2^7-1 encodes as `0b110x_xxxx xxxx_xxxx xxxx_xxxx`
// 2^21 - 2^28-1 encodes as `0b1110_xxxx xxxx_xxxx xxxx_xxxx xxxx_xxxx`
// ... And so on.
//
// For 64 bit integers it would be tempting to use:
// 0x1111_1111 1111_1111 xxxx_xxxx ....
// ... Since then there would be at most 2 bytes of overhead (or 4 bytes of overhead for 128 bits).
// But that breaks the pattern, so instead it uses this as the maximum encoding for 64 bits:
// 0x1111_1111 1111_1111 0xxx_xxxx ...
// And for 128 bits:
// 0x1111_1111 1111_1111 1111_1111 1111_1111 0xxx_xxxx ...
const assert = (a: boolean, msg?: string) => {
if (!a) throw Error(msg ?? 'Assertion failed')
}
/**
* Maximum number of bytes needed to store a javascript `number` up to 64 bits.
* This is the max size of numbers when calling `encode[Into]` and `decode`.
*
* Implementor's note: We require allocation of 9 bytes, but this could
* actually be set to 8 since we don't support normal numbers past
* MAX_SAFE_INTEGER (53 bits). The largest safe integer fits in 8 bytes, not 9.
*/
export const MAX_INT_LEN = 9
/**
* Maximum number of bytes needed to store the biggest supported bigint (128 bits).
* This is the max size of numbers when calling `encode[Into]BN` and `decodeBN`.
*/
export const MAX_BIGINT_LEN = 19
/**
* Assuming the start of a Uint8Array contains a varint, this method return the number of bytes
* the varint takes up
*/
export function bytesUsed(bytes: Uint8Array): number {
// Pull out the first 4 bytes. We'll never encode a number larger than 2^128 with this
// encoder, but that gives us up to 3 bytes with 1 bits in them.
// The input byte array might be smaller than 4 bytes long - but bit shift coerces undefined
// to 0, so conveniently enough, this works fine anyway.
const x = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]
// console.log('x', ~x, (~x).toString(2).padStart(32, '.'), Math.clz32(~x))
return Math.clz32(~x) + 1
}
/**
* This method checks to see if the given byte buffer has been filled with enough bytes
* to contain a varint. This is useful for network protocols where messages are prefixed
* with a length (varint), but you don't know if you've read enough bytes to contain the
* message's length (since the length of the length is variable!)
*
* You could always just try and parse the next number and catch the thrown exception, but
* its better if exceptions aren't thrown on the normal execution path in javascript.
*/
export function bufContainsVarint(bytes: Uint8Array): boolean {
const availableBytes = bytes.byteLength
if (availableBytes === 0) return false
if (availableBytes >= MAX_BIGINT_LEN) return true
// Ok, do a more expensive check.
const b0 = bytes[0]
let x = (b0 << 24)
// This logic is pretty ugly. Probably worth cleaning it up.
if (b0 === 0xff) {
// We need the second byte.
if (availableBytes <= 1) return false
const b1 = bytes[1]
x |= (b1 << 16)
// And the 3rd byte. This is only needed for massive numbers (120 byte range)
if (b1 === 0xff) {
if (availableBytes <= 2) return false
const b2 = bytes[2]
x |= (b2 << 8)
// And the 4th byte.
if (b2 === 0xff) {
if (availableBytes <= 3) return false
const b3 = bytes[3]
x |= b3
}
}
}
const bytesUsed = Math.clz32(~x) + 1
return availableBytes >= bytesUsed
}
function leadingOnes(n: number): number {
return Math.clz32(~(n << 24))
}
// *** Encoding and decoding regular numbers
const VARINT_ENC_CUTOFFS = [1 << 7]
for (let i = 1; i < 7; i++) {
// There's only 7 values smaller than the MAX_SAFE_INTEGER cutoff.
VARINT_ENC_CUTOFFS[i] = (VARINT_ENC_CUTOFFS[i - 1] + 1) * (1 << 7)
}
VARINT_ENC_CUTOFFS.push(Number.MAX_VALUE)
/**
* Encode the given unsigned number as a varint. Returns the varint in a Uint8Array.
*
* This method is a wrapper around `encodeInto`. If you're encoding into
* a buffer, its more efficient to use `encodeInto` directly to avoid
* the unnecessary Uint8Array allocation here and the copy into the destination
* buffer.
*
* NOTE: This method uses unsigned varint encoding. If you want to encode a signed
* number, call encode(zigzagEncode(num)).
*/
export function encode(num: number): Uint8Array {
const result = new Uint8Array(MAX_INT_LEN)
const bytesUsed = encodeInto(num, result, 0)
return result.slice(0, bytesUsed)
}
/**
* Encode the specified unsigned number into varint encoding, into the provided
* Uint8Array at the specified offset. Returns number of bytes consumed in dest.
* The passed array must have enough capacity for MAX_INT_LEN bytes (9 bytes).
*
* The number must be within the javascript safe integer range (53 bits).
*
* NOTE: This method only handles unsigned integers. Use zigzag encoding for signed
* integers before passing your number into this method. Eg encodeInto(zigzagEncode(num), ..)
**/
export function encodeInto(num: number, dest: Uint8Array, offset: number): number {
if (num > Number.MAX_SAFE_INTEGER) throw Error('Cannot encode integers above MAX_SAFE_INTEGER')
if (num < 0) throw Error('Varint encoding: Number must be non-negative')
let prefix = 0
for (let i = 0; i < VARINT_ENC_CUTOFFS.length; i++) {
if (num < VARINT_ENC_CUTOFFS[i]) {
if (i > 0) num -= VARINT_ENC_CUTOFFS[i - 1]
// console.log('num', num, 'prefix', prefix)
// console.log('i', i, 'prefix', prefix)
for (let j = i; j > 0; j--) {
dest[offset + j] = num & 0xff
// I'd rather bitshift, but that coerces to a u32.
// num >>= 8
num = Math.floor(num / 256)
}
assert((prefix & num) === 0) // Must never have overlapping bits.
assert(num >= 0)
dest[offset] = prefix | num
return i + 1
}
// prefix = (prefix << 1) + 2
prefix = (prefix >> 1) + 0x80
}
throw Error('unreachable')
}
/**
* Decode the varint contained in a Uint8Array. The number is returned.
*
* This method might not use all the bytes of the result. Use bytesUsed() to
* figure out how many bytes of the input were consumed by this method.
*/
export function decode(bytes: Uint8Array): number {
if (bytes.length === 0) throw Error('Unexpected end of input')
const b0 = bytes[0]
if (!(b0 & 0b1000_0000)) return b0 // Most common case.
const numBytes = leadingOnes(b0) + 1
if (bytes.length < numBytes) {
throw Error('Unexpected end of input')
}
let val = b0 & ((1 << (9 - numBytes)) - 1)
for (let i = 1; i < numBytes; i++) {
const b = bytes[i]
val = (val * 256) + b
}
val += VARINT_ENC_CUTOFFS[numBytes - 2];
return val
}
// Bigint variants
// With bigints, we can store numbers up to 2^128.
const common_mult_n = 1n << 7n
const VARINT_ENC_CUTOFFS_BIGINT = [common_mult_n]
// Enough for u128.
for (let i = 1; i < 19; i++) {
VARINT_ENC_CUTOFFS_BIGINT[i] = (VARINT_ENC_CUTOFFS_BIGINT[i - 1] + 1n) * common_mult_n
}
/**
* Encode the given bigint as a varint. Returns the encoded number in a Uint8Array.
*
* This method is a wrapper around `encodeIntoBN`. If you're encoding into
* a buffer, its more efficient to use `encodeIntoBN` directly to avoid
* the unnecessary Uint8Array allocation here and the copy into the destination
* buffer.
*
* NOTE: This method uses unsigned varint encoding. If you want to encode a signed
* number, call encodeBN(zigzagEncodeBN(num)).
*/
export function encodeBN(num: bigint): Uint8Array {
const result = new Uint8Array(MAX_BIGINT_LEN)
const bytesUsed = encodeIntoBN(num, result, 0)
return result.slice(0, bytesUsed)
}
/** The largest unsigned bigint we can encode (2^128 - 1) */
export const MAX_SAFE_BIGINT = 2n**128n - 1n
/**
* Encode the specified unsigned bigint into varint encoding, into the provided
* Uint8Array at the specified offset. Returns number of bytes consumed in dest.
* The passed array must have enough capacity for MAX_BIGINT_LEN bytes (19 bytes).
*
* NOTE: This method only handles unsigned integers. Use zigzag encoding for signed
* integers before passing your number into this method. Eg:
* encodeIntoBN(zigzagEncodeBN(num), ...).
*
* bijective-varint encoding only supports numbers up to 128 bits. This method
* will fail (throw an exception) if you pass a number which does not fit within
* the safe range.
**/
export function encodeIntoBN(num: bigint, dest: Uint8Array, offset: number): number {
if (num < 0n) throw Error('Varint encoding: Number must be non-negative')
// When we can, its faster to immediately convert to a Number rather than deal with BigInts.
if (num < Number.MAX_SAFE_INTEGER) return encodeInto(Number(num), dest, offset)
if (num > MAX_SAFE_BIGINT) throw Error('Cannot encode unsigned integers above 2^128') // Could support them pretty easily tho.
// let prefix = 0
for (let i = 0; i < VARINT_ENC_CUTOFFS_BIGINT.length; i++) {
if (num < VARINT_ENC_CUTOFFS_BIGINT[i]) {
if (i > 0) num -= VARINT_ENC_CUTOFFS_BIGINT[i - 1]
// We're going to use 7*(i+1) bits to store the data.
// There will be i x 1-bits at the start, and a 0.
// Prefix always fits in a normal int.
let leadingOnes = i
for (; leadingOnes >= 8; leadingOnes -= 8) {
dest[offset++] = 0xff
}
// & 0xff is only here to make the number positive, but its not necessary.
const prefix = (0xff << (8-leadingOnes)) & 0xff
const trailingBits = i * 7 + leadingOnes
// console.log('prefix', prefix.toString(2), num >> BigInt(trailingBits), BigInt.asUintN(8, num >> BigInt(trailingBits)).toString(2))
// I'm filling the buffer left to right here,
// but it might be faster / better to fill it right to left?
dest[offset++] = prefix | Number(num >> BigInt(trailingBits))
assert(trailingBits % 8 === 0)
for (let j = trailingBits - 8; j >= 0; j -= 8) {
// Using BigInt.asUintN here to truncate so we don't overflow the Number. Could equally (x & 0xffn).
dest[offset++] = Number(BigInt.asUintN(8, num >> BigInt(j)))
}
return i + 1 // i+1 === change in offset.
}
}
throw Error('unreachable')
}
/**
* Decode the varint contained in a Uint8Array into a bigint. The number is
* returned.
*
* This method might not use all the bytes of the result. Use bytesUsed() to
* figure out how many bytes of the input were consumed by this method.
*
* Callers must ensure the entire number is ready in the buffer before calling
* this method.
*/
export function decodeBN(bytes: Uint8Array): bigint {
if (bytes.length === 0) throw Error('Unexpected end of input')
const b0 = bytes[0]
if ((b0 & 0b1000_0000) === 0) return BigInt(b0)
const numBytes = bytesUsed(bytes)
assert(numBytes >= 2)
// console.log('numBytes', numBytes)
if (bytes.length < numBytes) throw Error('Unexpected end of input')
// There are numBytes-1 leading ones, then a 0, then numBytes * 7 bits of BE data.
let b = numBytes
let offset = 0
while (b >= 8) {
b -= 8
++offset
}
// let val = b0 & ((1 << (9 - numBytes)) - 1)
let val = BigInt(bytes[offset++] & (0xff >> b))
// console.log('v0', val)
for (; offset < numBytes; ++offset) {
val = (val * 256n) + BigInt(bytes[offset])
// console.log('v', val)
}
val += VARINT_ENC_CUTOFFS_BIGINT[numBytes - 2];
return val
}
/** Zigzag encode a signed integer in a number into an unsigned integer */
export function zigzagEncode(val: number): number {
return val < 0
? -val * 2 - 1
: val * 2
}
/** Zigzag decode an unsigned integer into a signed integer */
export function zigzagDecode(val: number): number {
return (val % 2) === 1
? -(val + 1) / 2
: val / 2
}
/** Zigzag encode a signed integer in a bigint into an unsigned bigint */
export function zigzagEncodeBN(val: bigint): bigint {
return val < 0
? (-val << 1n) - 1n
: val << 1n
}
/** Zigzag decode an unsigned bigint into a signed bigint */
export function zigzagDecodeBN(val: bigint): bigint {
return (val % 2n) === 1n
? -(val + 1n) / 2n // Will truncate.
: val / 2n
}