Deriving a Fast Fixed-Width Hex Encoder in Go

A micro-optimisation case study: starting from what encoding/hex does per byte, the nurago uhex package derives a branchless fixed-width encoder built on a 256-entry table and one 16-bit store per byte.


Hexadecimal encoding is a solved problem. The standard library has encoding/hex, and if you just want a string there is always fmt.Sprintf("%x", v). So why does nurago ship a dedicated uhex package?

Because the places where hex encoding runs hottest (trace IDs, hashes, log fields, protocol framing) share a property the general tools cannot exploit: the width is fixed and known at compile time. uhex is a case study in what that one constraint buys you. This article derives the implementation step by step, starting from the standard library and removing one cost at a time.


Step 0: what encoding/hex does per byte

The core of hex.Encode is a loop like this:

for _, v := range src {
    dst[j] = hextable[v>>4]
    dst[j+1] = hextable[v&0x0f]
    j += 2
}

Per input byte: two nibble extractions, two lookups into a 16-byte table, two single-byte stores, plus loop bookkeeping and bounds checks on the dst indexing, because the compiler cannot prove at compile time that dst is long enough. For variable-length input this is the right shape, and there is not much fat to trim. But when the width is fixed, each of those costs can be attacked separately.

Step 1: fold two lookups into one

A byte has only 256 possible values, and each encodes to exactly two lowercase hex characters. So precompute all 256 two-character results once, at package initialisation, and per-byte encoding collapses to a single table lookup.

uhex stores each pair not as [2]byte but packed into a uint16:

t[i] = uint16(hexTable[i>>4]) | uint16(hexTable[i&0xf])<<8

The character for the high nibble sits in the low half of the uint16, so writing the value little-endian lands both digits in the output in the right order. Two lookups and two byte stores have become one lookup and one 16-bit store, and on little-endian hardware that store is a single instruction.

Step 2: remove the loop

The remaining per-byte cost is the loop itself: the counter, the branch back, and the bounds checks. With the width fixed, all of it can go. Each encoder is fully unrolled for its width; here is the 64-bit one, verbatim:

// Hex64UB writes the zero-padded, lowercase hexadecimal encoding of n into dst.
func Hex64UB(n uint64, dst *[16]byte) {
    binary.LittleEndian.PutUint16(dst[0:2], hex16[byte(n>>56)])
    binary.LittleEndian.PutUint16(dst[2:4], hex16[byte(n>>48)])
    // ... four more pairs ...
    binary.LittleEndian.PutUint16(dst[12:14], hex16[byte(n>>8)])
    binary.LittleEndian.PutUint16(dst[14:16], hex16[byte(n)])
}

Two details do the quiet work here. The destination is *[16]byte, not []byte, so every slice index is a constant into an array of known size and the compiler eliminates all bounds checks (building the package with -d=ssa/check_bce reports none). And there is no length argument to validate, so there are no data-dependent branches at all: the cost is one table load and one 16-bit store per byte, identical for every input value. Taking an integer directly also skips a step that encoding/hex forces on you, since that API only accepts byte slices and an integer must first be serialised into a scratch buffer.

Step 3: control the allocation

The last cost is the heap. uhex offers four helpers per width, and the suffix tells you the contract:

import "github.com/tecnickcom/nurago/pkg/uhex"

// Slice-returning: convenient, stack-allocates when the result does not escape.
b := uhex.Hex64(0xDEADBEEF)        // 16 bytes, zero-padded: "00000000deadbeef"

// Buffer-writing: zero allocations.
var buf [16]byte
uhex.Hex64UB(0xDEADBEEF, &buf)     // integer -> hex
uhex.Hex64BB([8]byte{1, 2, 3, 4, 5, 6, 7, 8}, &buf) // byte array -> hex

The slice-returning helpers (Hex64, Hex64B, and the narrower widths) fill a local array and return a slice over it. Whether that array reaches the heap is decided by escape analysis at your call site: these wrappers are small enough to inline, so if the returned slice does not outlive the call, the array stays on the caller’s stack and nothing is allocated. If it does escape, you pay one small allocation.

The buffer-writing helpers (...UB, ...BB) write into a caller-owned array and do not allocate, regardless of what you do with the result. The 32-bit and narrower ones are within the compiler’s inlining budget (-gcflags=-m confirms it); the 64-bit bodies are too large to inline but remain a plain call with no setup cost.


Did each step pay off?

The package’s documentation pins the expected shape of the result: on the order of two to three times faster than encoding/hex for these fixed widths, and roughly an order of magnitude faster than fmt.Sprintf. The numbers below are from one run on one machine (Go 1.26.5, linux/amd64, 12th Gen Intel Core i7-1260P), combining the package’s own benchmarks with a small comparison harness; absolute values will differ on your hardware.

ApproachTimeAllocations
uhex.Hex64UB (uint64 -> 16 hex)~2.0 ns/op0 allocs
uhex.Hex64BB (8 bytes -> 16 hex)~2.4 ns/op0 allocs
hex.Encode (8 bytes -> 16 hex)~5.9 ns/op0 allocs
binary.BigEndian.PutUint64 + hex.Encode (uint64)~6.2 ns/op0 allocs
fmt.Sprintf("%016x", u)~77 ns/op24 B, 2 allocs

That is about 2.4x over encoding/hex on the byte-array path and about 3.2x on the integer path, consistent with the documented range. The gap to fmt.Sprintf is wide because fmt pays for reflection, format-string parsing, and two allocations before any hex digit is written.

One caveat the package documents, and the measurements confirm: if you need a heap-allocated result, the allocation dominates. string(uhex.Hex64(u)) measured ~16 ns/op against ~22 ns/op for hex.EncodeToString, both with one allocation. The derivation above buys most of its advantage only when the result stays out of the allocator’s hands, which is exactly what the ...UB and ...BB variants provide.


When this matters, and when it does not

Reach for uhex when you encode at a fixed, known width, repeatedly, on a path hot enough to appear in a profile: emitting trace IDs, formatting hashes or checksums, building tokens. Inside nurago it is the encoder behind random ’s allocation-free Universally Unique Identifier (UUID) formatting, which stitches Hex32BB and Hex16BB calls around the dashes of the canonical form.

For everything else, encoding/hex is the right tool. Variable-length input, streaming, and decoding are exactly the cases uhex deliberately does not cover; it stops at eight input bytes because that is where a compile-time-unrolled, table-driven encoder can beat a general one. A micro-optimisation like this earns its keep only inside its constraint, and the package is careful to state the constraint rather than pretend it is a general replacement.