Hacking the Go compiler to map IPv4 to IPv6 faster
Translated by a model; the original is in Russian.
The net/netip package offers an Unmap() method that extracts the IPv4 address wrapped inside an IPv4-mapped IPv6 address: from ::ffff:203.0.113.10 it returns 203.0.113.10. But there is no method for the reverse — no Map() or To6(). Implementing one is trivial, yet the Go maintainers rejected it, arguing that users should write netip.AddrFrom16(ip.As16()) and trust the optimizer. The catch: today that pattern runs eight times slower than a native method would. Vincent Bernat set out to see whether the compiler itself could be taught to fold the sequence.
Three paths
Bernat weighs three options. The first is to add To6() straight to the standard library: internally Addr stores the address as a 128-bit value plus a z field, so it suffices to set that field appropriately. The second is an external helper wrapping AddrFrom16(ip.As16()), exactly as the maintainers suggest. The third is an unsafe trick that rewrites the z field through a proxy struct with the same memory layout.
The benchmarks on Go 1.27.1 are telling: the built-in solution costs 0.88 ns per operation, the unsafe version matches it, and the recommended helper costs 7.14 ns. The assembly explains why: the helper packs the address into a 16-byte array, copies it, and unpacks it again — three redundant blocks of instructions that a human would trivially elide but the compiler cannot.
The hammer and the screwdriver
The first, blunt attempt rewrites the pattern into a struct literal during IR construction (noding). It works — the generated code becomes minimal — but it relies on the internals of netip.Addr, defaces a phase whose job is merely to translate the tree faithfully, and clearly won't be accepted.
The proper home for such an optimization is the generic SSA phase and the memcombine pass. Bernat walks through, step by step, the rewrite rules needed: shortcutting a load through a move, forwarding a stored value into a later load, and cancelling two consecutive byte swaps. Most of these already live in generic.rules; only a variant of load-through-Move without an offset was missing.
Out of order
Here comes the snag: the rewrite rules fire before memcombine, during the late opt pass, when the byte operations are still expanded into sixteen separate loads and stores. As a result some bytes get forwarded while others remain loads — and memcombine can no longer merge them. Worse, the rule added in Go 1.27 introduces a regression of its own: the code comes out worse than in 1.26.8.
The quick workaround is to run an extra early round of memcombine before late opt. With that, the code folds back to the ideal minimum, and the benchmark confirms the helper speeds up by nearly 86%.
Bernat is candid: the Go maintainers will probably reject this too, on account of the extra pass. So he filed issue #81994 proposing Addr.To6(): either the sheer complexity of the exercise and the 1.27 regression convince them that a simple method is better, or they point him to a better way. Either way, he concludes, digging through the compiler's innards taught him a great deal.
Source: Lobsters
Comments
Sign in, to comment.