❌

Lees weergave

Distribution Release: Besgnulinux 1.0 "Openbox"

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. The Besgnulinux team has announced the release of a new edition of its Debian-based Linux distribution - Besgnulinux 1.0 which features the Openbox window manager: "It had been a long time since I last tested Openbox. I initially created this version for testing purposes. However, once it turned....
  •  

Amazon Launches Early Prime Day Discounts on AirPods, iPad, MacBook Air, and More

Earlier this week, we began tracking early Prime Day deals on Apple-related accessories, and now Amazon has introduced major early deals on Apple products as well. This includes AirPods, iPads, MacBooks, and more, all available at some of the lowest prices of the year so far.

Note: MacRumors is an affiliate partner with Amazon. When you click a link and make a purchase, we may receive a small payment, which helps us keep the site running.

As is typical for Prime Day deals, these markdowns are very time sensitive, so sales listed below may disappear fast, and new ones may appear even faster. With this in mind, we'll keep this article updated throughout the event, and keep an eye on the MacRumors front page as we'll be posting particularly great deals in separate articles this week.



Lastly, and perhaps most importantly, Amazon Prime Day requires you to have an Amazon Prime membership to take advantage of the discounts. Amazon Prime costs $14.99 per month or $139.00 per year, and it comes with a 30-day free trial for new subscribers.

AirPods




The best AirPods discount so far this Prime Day is on the AirPods Pro 3, available for the record low price of $179.00, down from $249.00. We haven't seen a deal this good since over the summer, during the first Prime Day event in June.



MacBook Air




Amazon is taking $200 off nearly every M5 MacBook Air model for Prime Big Deal Days, including both 13-inch and 15-inch models. These are the best prices we've seen on the M5 MacBook Air in the wake of Apple's summertime price hikes.

In terms of 13-inch models, Amazon has the 16GB/512GB 13-inch MacBook Air for $1,099.00, down from $1,299.00. This one is available in three colors on Amazon, and it's accompanied by the 16GB/1TB and 24GB/1TB model for $200 off as well.





For the 15-inch M5 MacBook Air models, prices start with the 16GB/512GB model for $1,299.00, down from $1,499.00. You can also get the 16GB/1TB model for $1,599.00, down from $1,799.00, all $200 off and the best prices we've seen in months.




Apple Watch




Rare deals have hit the Apple Watch SE 3 for Prime Big Deal Days, with prices now starting at $199.00 for the 40mm GPS model and increasing to $229.00 for the 44mm GPS model. Both of these are $50 discounts and matches for the record low prices on each device.




For the Apple Watch Series 12, you can get the 42mm GPS model for $349.99 and the 46mm GPS model for $399.99, both of which are $49 discounts and new all-time low prices. Both of these models have two colors available at these all-time low prices, and you'll find a few cellular options on sale as well.




For the Apple Watch Ultra 4, you can get the Natural model with the Translucent Gray Ocean Band for $699.99, down from $799.00. This is another new all-time low price for Prime Day, and as of writing it's the only color we're tracking at this price. Additionally, there are a few Milanese Loop models on sale for $99 off ahead of Prime Day.



M4 iPad Air




Amazon this week has multiple discounts on the M4 iPad Air, providing up to $149 off these 2026 models. These remain the best prices we've seen in the wake of Apple's summer price hikes, and many deals are being matched at Best Buy.




The new iPad Air features the M4 chip, C1X modem, and N1 networking chip, which brings support for Wi-Fi 7 and Bluetooth 6. In terms of design, the 2026 models are identical to the 2025 iPad Air tablets, with an edge-to-edge display, slim bezels, and aluminum chassis.

11-inch M4 iPad Air



13-inch M4 iPad Air



iPad Mini 7




Amazon has low prices on every Wi-Fi model of the iPad mini 7, starting at $499.00 for the 128GB Wi-Fi tablet, down from $599.00. You'll also find a few deals on cellular models during this early Prime Day sale.





If you're on the hunt for more discounts, be sure to visit our Apple Deals roundup where we recap the best Apple-related bargains of the past week.




Deals Newsletter


Interested in hearing more about the best deals you can find in 2026? Sign up for our Deals Newsletter and we'll keep you updated so you don't miss the biggest deals of the season!




Related Roundup: Apple Deals

This article, "Amazon Launches Early Prime Day Discounts on AirPods, iPad, MacBook Air, and More" first appeared on MacRumors.com

Discuss this article in our forums

  •  

AirPods Pro 3 Hit Record Low $179 Price During Early Prime Day Sale

Amazon has the AirPods Pro 3 available for $179.00 this week, down from $249.00. This is a match of the all-time low price on the AirPods Pro 3, which we haven't tracked since around June.

Note: MacRumors is an affiliate partner with some of these vendors. When you click a link and make a purchase, we may receive a small payment, which helps us keep the site running.

This model of the AirPods Pro launched in September 2025 and has 2x better Active Noise Cancellation than the previous generation, better audio quality, a revised fit that's meant to improve comfort and stability, Live Translation for in-person conversations, and heart rate sensing for workouts.



Head to our full Deals Roundup to get caught up with all of the latest deals and discounts that we've been tracking over the past week.




Deals Newsletter


Interested in hearing more about the best deals you can find in 2026? Sign up for our Deals Newsletter and we'll keep you updated so you don't miss the biggest deals of the season!




Related Roundup: Apple Deals

This article, "AirPods Pro 3 Hit Record Low $179 Price During Early Prime Day Sale" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Vincent Bernat: Hacking the Go compiler to efficiently map IPv4 to IPv6

netip.Addr features an Unmap() method returning the unwrapped IPv4 contained in an IPv4-mapped IPv6 address: from ::ffff:203.0.113.10 or ::ffff:cb00:710a, it returns 203.0.113.10.1 There is no Map() or To6() method for the reverse direction. Such a method is trivial to implement, but Go maintainers have rejected it on the grounds that users should write netip.AddrFrom16(ip.As16()) and let the compiler optimize it.2 Today, this pattern is eight times slower than a native method. How can we teach the compiler to optimize this sequence?

The alternatives

Let’s explore three ways to implement the map semantics for netip.Addr. My favorite is to add it to the Go standard library. Go maintainers prefer a small external helper chaining netip.AddrFrom16() and netip.Addr.As16(), hoping the compiler eventually optimizes it. The unsafe package opens a third path, with the same performance as the first solution.

Modifying the Go standard library

Internally, netip.Addr stores any IP address as a 128-bit value with an extra field z to encode the family and the zone:

type Addr struct {
    addr uint128
    z unique.Handle[addrDetail]
}

type addrDetail struct {
    isV6   bool   // IPv4 is false, IPv6 is true.
    zoneV6 string // != "" only if IsV6 is true.
}

var (
    z0    unique.Handle[addrDetail]
    z4    = unique.Make(addrDetail{})
    z6noz = unique.Make(addrDetail{isV6: true})
)

AddrFrom4() encodes an IPv4 address as an IPv4-mapped IPv6 address and sets z to the unique value z4:

// AddrFrom4 returns the address of the IPv4 address given by the bytes in addr.
func AddrFrom4(addr [4]byte) Addr {
    return Addr{
        addr: uint128{
            0,
            0xffff00000000 |
                uint64(addr[0])<<24 | uint64(addr[1])<<16 |
                uint64(addr[2])<<8 | uint64(addr[3])},
        z: z4,
    }
}

Unmap() turns an IPv4-mapped IPv6 address into an IPv4 address by setting the z field to z4:

func (ip Addr) Unmap() Addr {
    if ip.Is4In6() {
        ip.z = z4
    }
    return ip
}

Implementing the reverse direction inside the Go standard library is trivial: we set the z field to z6noz if the address is IPv4.

// To6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func (ip Addr) To6() Addr {
    if ip.Is4() {
        ip.z = z6noz
    }
    return ip
}

Update (2026-10)

Instead of patching Go, we can use the -overlay flag of go build to hijack any package, including the standard library. In our case, it can add To6() to net/netip. Yet it is cumbersome:3 the flag expects a JSON file mapping absolute paths in GOROOT to replacement files, and you need to add it to every go command.

As a helper

We can’t access the z field from outside the net/netip package. Instead, we build a small helper around the netip.AddrFrom16(ip.As16()) pattern:

// AddrTo6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func AddrTo6(ip netip.Addr) netip.Addr {
    if ip.Is4() {
        ip = netip.AddrFrom16(ip.As16())
    }
    return ip
}

As an unsafe function

Another solution uses the unsafe package to alter the Addr struct through a proxy with the same memory layout:4

// addrProxy has the same memory layout as netip.Addr.
type addrProxy struct {
    addr [2]uint64      // netip.uint128
    z    unsafe.Pointer // unique.Handle[netip.addrDetail]
}

var (
    anyIPv6    = netip.IPv6Unspecified()
    netipZ6noz = (*addrProxy)(unsafe.Pointer(&anyIPv6)).z
)

// AddrTo6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func AddrTo6(ip netip.Addr) netip.Addr {
    if !ip.Is4() {
        return ip
    }
    (*addrProxy)(unsafe.Pointer(&ip)).z = netipZ6noz
    return ip
}

Benchmarks

On my computer, with Goβ€―1.27.1, the standard library solution costs 0.88 ns per operation, while the solution favored by Go maintainers costs 7.14 ns. The unsafe solution matches the performance of the first one.

goos: linux
goarch: amd64
pkg: github.com/vincentbernat/go-netip-addrto6
cpu: AMD Ryzen 5 5600X 6-Core Processor
                   β”‚     sec/op     β”‚
AddrTo6/safe            7.137n Β± 0%
AddrTo6/unsafe         0.8682n Β± 2%
AddrTo6/builtin        0.8775n Β± 2%

Assembly code

Let’s check the assembly code the compiler generates for each solution.5 The one built into the standard library looks like this:6

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ  net/netipΒ·z4(SB), CX     ; check "z" if this is an IPv4 address
 JNE   end                      ; if not, stop here
 MOVQ  net/netipΒ·z6noz(SB), CX  ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

Go’s assembly language is not a direct representation of the underlying machine language: it operates on a semi-abstract instruction set derived from Planβ€―9’s assembler. It has four pseudo-registers: FP (frame pointer for function arguments), PC (program counter), SB (static base pointer for global symbols), and SP (stack pointer). It also has architecture-specific registers like AX, CX, DX, BX, SI, DI, and R8 to R15. Instructions storing data use their last argument as the destination. Instructions can carry an explicit size suffix: MOVB moves a byte, MOVWβ€―16 bits, MOVLβ€―32 bits, and MOVQβ€―64 bits. In the example above, the first instruction compares the 64-bit value z4 with the CX register.

The unsafe solution looks almost the same:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ  net/netipΒ·z4(SB), CX  ; check "z" if this is an IPv4 address
 JNE   end                   ; if not, stop here
 MOVQ  netipZ6noz(SB), CX    ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

The helper solution has far more instructions. To understand why, let’s look at the code for As16() and AddrFrom16(). They are short enough for the compiler to inline them.

func (ip Addr) As16() (a16 [16]byte) {
    byteorder.BEPutUint64(a16[:8], ip.addr.hi)
    byteorder.BEPutUint64(a16[8:], ip.addr.lo)
    return a16
}

func AddrFrom16(addr [16]byte) Addr {
    return Addr{
        addr: uint128{
            byteorder.BEUint64(addr[:8]),
            byteorder.BEUint64(addr[8:]),
        },
        z: z6noz,
    }
}

We can already guess the pattern to optimize: the code packs the IP address into an array, copies it, then unpacks it. If we inline the Go code by hand, we get:

func AddrTo6(input netip.Addr) netip.Addr {
    if !input.Is4() {
        return input
    }

    var a16 [16]byte
    byteorder.BEPutUint64(a16[:8], input.addr.hi)
    byteorder.BEPutUint64(a16[8:], input.addr.lo)

    addr := a16

    var output netip.Addr
    output.addr.hi = byteorder.BEUint64(addr[:8])
    output.addr.lo = byteorder.BEUint64(addr[8:])
    output.z = netip.z6noz
    return output
}

As humans, we can mentally derive the optimized form:

func AddrTo6(input netip.Addr) netip.Addr {
    if !input.Is4() {
        return input
    }
    var output netip.Addr
    output.addr.hi = input.addr.hi
    output.addr.lo = input.addr.lo
    output.z = netip.z6noz
    return output
}

Unfortunately, as of Goβ€―1.26.8, the compiler is not smart enough to do the same:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
; Push the stack (32 bytes):
;    0(SP) addr netip.uint128
;   16(SP) a16 [16]byte
 PUSHQ   BP
 MOVQ    SP, BP
 SUBQ    $32, SP

 CMPQ    net/netipΒ·z4(SB), CX  ; check "z" if this is an IPv4 address
 JNE     end                   ; if not, stop here

; Pack: byteorder.BEPutUint64(a16[:8], input.addr.hi)
;       byteorder.BEPutUint64(a16[8:], input.addr.lo)
 MOVBEQ  AX, net/netipΒ·a16+16(SP)
 MOVBEQ  BX, net/netipΒ·a16+24(SP)

; addr = a16, 16 bytes at once through the vector register X0
 MOVUPS  net/netipΒ·a16+16(SP), X0
 MOVUPS  X0, net/netipΒ·addr(SP)

; CX = netip.z6noz
 MOVQ    net/netipΒ·z6noz(SB), CX
; Unpack: output.addr.hi = byteorder.BEUint64(addr[:8])
;         output.addr.lo = byteorder.BEUint64(addr[8:])
 MOVBEQ  net/netipΒ·addr(SP), AX
 MOVBEQ  net/netipΒ·addr+8(SP), BX

end:
 ADDQ    $32, SP
 POPQ    BP
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

The compiler does a decent job on the byte shuffling: the eight byte stores of BEPutUint64() become a single MOVBEQ, which stores a register byte-swapped. The eight byte loads of BEUint64() become a single MOVBEQ the other way round.7 Three groups of instructions remain: a pack, a copy, and an unpack.

Hacking the Go compiler

The Go compiler has several phases:

Parsing
The compiler tokenizes and parses the source code. It builds a syntax tree for each source file.
Type checking
The compiler maps each identifier to the object it denotes, folds constants, and infers the type of every expression.
IR construction
The compiler converts the syntax tree and its types into its own intermediate representation (IR). This process, called β€œnoding,” goes through a serialization format named unified IR.
Middle end
The compiler performs several optimization passes on the IR, such as devirtualization, function call inlining, and escape analysis.
Walk
This phase runs two steps: order of evaluation decomposes complex statements into simpler ones, and desugaring transforms higher-level Go constructs, like switch or channels, into more primitive instructions or calls to the runtime.
Generic SSA
The compiler converts the IR into Static Single Assignment (SSA) form, a lower-level intermediate representation suited for machine-independent optimizations and rewrite rules.
Machine code generation
The compiler rewrites the SSA form into machine-specific variants, allocates registers, and applies more optimization passes. At the end, the assembler turns the generated instructions into machine code.

The hammer

My first idea is to replace occurrences of netip.AddrFrom16(ip.As16()) with netip.Addr{addr: ip.addr, z: netip.z6noz} as early as possible, during the β€œnoding” process. Before that, the type checking phase prevents us from accessing unexported struct fields.

Goβ€―1.27 introduced a convenient debug option to dump the IR of a function at interesting points during compilation:

$ GOTOOLCHAIN=go1.27.1 GOAMD64=v3 go build -a -gcflags="-d=astdump=AddrTo6Safe" .
Writing text ast output for AddrTo6Safe to AddrTo6Safe.ast
Writing html ast output for AddrTo6Safe to AddrTo6Safe.html
Writing html syntax output for AddrTo6Safe to AddrTo6Safe.syntax.html

In the HTML file, the first column shows the IR as it comes out of noding:

DCLFUNC addrto6.AddrTo6Safe ABI:ABIInternal FUNC-func(netip.Addr) netip.Addr
DCLFUNC-Dcl
. NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. NAME-addrto6.~r0 Class:PPARAMOUT Offset:0 OnStack netip.Addr
DCLFUNC-body
. IF # ipv6_safe.go:11:2
. IF-Cond
. . CALLFUNC bool
. . CALLFUNC-Fun
. . . METHEXPR addrto6.Is4 FUNC-func(netip.Addr) bool
. . . . TYPE netip.Addr Class:PEXTERN Offset:0 type netip.Addr
. . CALLFUNC-Args
. . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. IF-Body
. . AS # ipv6_safe.go:12:6
. . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . . CALLFUNC netip.Addr
. . . CALLFUNC-Fun
. . . . NAME-netip.AddrFrom16 Class:PFUNC Offset:0 Used FUNC-func([16]byte) netip.Addr
. . . CALLFUNC-Args
. . . . CALLFUNC ARRAY-[16]byte
. . . . CALLFUNC-Fun
. . . . . METHEXPR addrto6.As16 FUNC-func(netip.Addr) [16]byte
. . . . . . TYPE netip.Addr Class:PEXTERN Offset:0 type netip.Addr
. . . . CALLFUNC-Args
. . . . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. RETURN # ipv6_safe.go:14:2
. RETURN-Results
. . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr

In the body of the if statement, we spot the calls to the method netip.Addr.As16() and to the function netip.AddrFrom16(). Our goal is to patch them with a struct literal:

IF-Body
. AS # ipv6_safe.go:12:6
. . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . STRUCTLIT netip.Addr
. . STRUCTLIT-List
. . . STRUCTKEY netip.addr
. . . . DOT netip.addr netip.uint128
. . . . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . . STRUCTKEY netip.z
. . . . NAME-netip.z6noz Class:PEXTERN Offset:0 unique.Handle[net/netip.addrDetail]

In noder’s reader.go, the expr() method builds the IR tree for an expression. At the end of the exprCall case, we add a call to a rewriteAddrFrom16As16() function. It takes the current node and returns the struct literal on success, or nil if the rewrite is not possible. First, we check that we have the expected pattern: a call to the netip.AddrFrom16() function with a call to the netip.Addr.As16() method as its only argument:

func rewriteAddrFrom16As16(n ir.Node) ir.Node {
    call, ok := n.(*ir.CallExpr)
    if !ok || call.Op() != ir.OCALLFUNC ||
        len(call.Args) != 1 || len(call.Init()) != 0 ||
        !isNetipFunc(call.Fun, "AddrFrom16") {
        return nil
    }
    inner, ok := call.Args[0].(*ir.CallExpr)
    if !ok || inner.Op() != ir.OCALLFUNC ||
        len(inner.Args) != 1 || len(inner.Init()) != 0 ||
        !isNetipFunc(inner.Fun, "Addr.As16") {
        return nil
    }
    x := inner.Args[0]
    // [...]
}

Then, we fetch netip.z6noz:

z6noz, err := lookupVar(ir.StaticCalleeName(call.Fun).Sym().Pkg, "z6noz")
if err != nil {
    return nil
}

And we build the struct literal:

typ := call.Type()
pos := call.Pos()
var list []ir.Node
for i, f := range typ.Fields() {
    var value ir.Node
    switch f.Sym.Name {
    case "addr":
        value = typecheck.DotField(pos, x, i)
    case "z":
        value = z6noz
    default:
        return nil
    }
    list = append(list, ir.NewStructKeyExpr(pos, f, value))
}
lit := ir.NewCompLitExpr(pos, ir.OSTRUCTLIT, typ, list)
lit.SetTypecheck(1)
return lit

Have a look at the complete patch.8 We can test it with the following commands:

$ cd src
$ ./make.bash
Building Go cmd/dist using /usr/lib/go-1.27. (go1.27.1 linux/amd64)
Building Go toolchain1 and bootstrap cmd/go (go_bootstrap) using /usr/lib/go-1.27.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 and commands using go_bootstrap and Go toolchain2.
Checking command staleness for linux/amd64.
---
Installed Go for linux/amd64 in /home/bernat/code/free/go
Installed commands in /home/bernat/code/free/go/bin
*** You need to add /home/bernat/code/free/go/bin to your PATH.
$ export PATH=$PWD/../bin:$PATH
$ go version
go version go1.28-devel_9834516e20 Sat Sep 12 08:23:11 2026 -0700 linux/amd64
$ go test net/netip/...
ok      net/netip   0.224s
$ cd ../../go-netip-addrto6
$ go test .
ok      github.com/vincentbernat/go-netip-addrto6   0.062s

The generated code for the helper is now the shortest possible version!

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ    net/netipΒ·z4(SB), CX     ; check "z" if this is an IPv4 address
 JNE     end                      ; if not, stop here
 MOVQ    net/netipΒ·z6noz(SB), CX  ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

Go maintainers are unlikely to accept this change. It relies on the internal structure of net/netip.Addr. It’s an ugly hack in the noder, whose job is to faithfully translate the type-checked AST into the IR. And it’s harder to maintain than adding a To6() method.

The screwdriver

The right place for such an optimization is the generic SSA phase. One of the last machine-independent passes is memcombine. With the appropriate debug flag, the compiler dumps the SSA form after this pass:9

$ GOTOOLCHAIN=go1.26.8 GOAMD64=v3 \
> go build -a -gcflags='-d=ssa/memcombine/dump=AddrTo6Safe' .
$ head -5 AddrTo6Safe_01__memcombine.dump
AddrTo6Safe func(netip.Addr) netip.Addr
  b2:
    (?) v1 = InitMem <mem>
    (?) v2 = SP <uintptr>
    (?) v3 = SB <uintptr>

The result of the memcombine pass follows the same structure as the assembly code for AddrTo6Safe() we looked at earlier: two stores, one move, and two loads we would like to optimize away.

; […]
  v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
  v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
  v466 = ArgIntReg <*netip.addrDetail> {ip+16} [2]  ; input.z
; […]
  v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
  v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
  v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
  v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
  v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
  v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)

  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16

  v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
  v416 = Load <uint64> v285 v286                    ; addr[:8]
  v299 = Bswap64 <uint64> v416                      ; output.addr.hi
  v174 = Load <uint64> v415 v286                    ; addr[8:]
  v39  = Bswap64 <uint64> v174                      ; output.addr.lo
; […]

Each line features a value identifier (v442), an operation with its type (Bswap64 <uint64>), and its arguments (v490).10 Values are the basic building blocks of SSA and are defined exactly once. Square brackets enclose integer parameters ([8]) and curly braces contain auxiliary arguments ({netip.addr}). Operations writing to memory produce a new memory state. Every memory operation takes the current state as its last argument, which keeps them in order.

On paper

Let’s focus on output.addr.lo, aka v39:

  v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
  v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
  v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
  v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
  v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
  v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
  v174 = Load <uint64> v415 v286                    ; addr[8:]
  v39  = Bswap64 <uint64> v174                      ; output.addr.lo

To simplify this code, we could apply three rewriting rules:

  1. The first one adds a shortcut when loading through a move: (Load (OffPtr [o] p) (Move p src mem)) => (Load (OffPtr [o] src) mem). This matches v174 with its arguments v415 and v286 and creates a new value v600:

      v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
      v600 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v174 = Load <uint64> v600 v282                    ; a16[8:]
      v39  = Bswap64 <uint64> v174                      ; output.addr.lo
    
  2. The second one simplifies a load following a store: (Load p (Store p x _)) => x. The load is forwarded: the stored value replaces it and no memory access remains. It matches v174. It notices that v600 and v173 are the same address and replaces v174 with a copy of v442:

      v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
      v600 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v174 = Copy <uint64> v442                         ; bswap(input.addr.lo)
      v39  = Bswap64 <uint64> v174                      ; output.addr.lo
    
  3. The last step cancels the two byte swaps: (Bswap64 (Bswap64 x)) => x. v39 becomes a copy of v490:

      v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
      v600 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v174 = Copy <uint64> v442                         ; bswap(input.addr.lo)
      v39  = Copy <uint64> v490                         ; output.addr.lo = input.addr.lo
    

If we ignore the values not needed to compute v39, only this SSA form remains:

  v490 = ArgIntReg <uint64> {ip+8} [1]  ; input.addr.lo
  v39  = Copy <uint64> v490             ; output.addr.lo = input.addr.lo

Let’s switch to output.addr.hi, aka v299:

  v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
  v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
  v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
  v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
  v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
  v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
  v416 = Load <uint64> v285 v286                    ; addr[:8]
  v299 = Bswap64 <uint64> v416                      ; output.addr.hi

To optimize it away, we also apply three rewriting rules:

  1. The first one also adds a shortcut when loading through a move, but without an offset: (Load p (Move p src mem)) => (Load src mem). This rewrites v416 to use arguments from v286:

      v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
      v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v416 = Load <uint64> v22 v282                     ; a16[:8]
      v299 = Bswap64 <uint64> v416                      ; output.addr.hi
    
  2. The second rule forwards a value stored one step earlier, skipping over a store to another address: (Load p (Store q _ (Store p x _))) => x. This matches v416: x is v542, p is v22 (&a16), q is v173 (&a16[8]), and p and q do not overlap for uint64.

      v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
      v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v416 = Copy <uint64> v542                         ; bswap(input.addr.hi)
      v299 = Bswap64 <uint64> v416                      ; output.addr.hi
    
  3. The third rule cancels two byte swaps: (Bswap64 (Bswap64 x)) => x. v299 becomes a copy of v502:

      v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
      v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v416 = Copy <uint64> v542                         ; bswap(input.addr.hi)
      v299 = Copy <uint64> v502                         ; output.addr.hi = input.addr.hi
    

If we remove the values not used to compute v299, we get this SSA form:

  v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi
  v299 = Copy <uint64> v502            ; output.addr.hi = input.addr.hi

In practice

Most of these rules already exist in generic.rules. They use conditions to validate their context: ssa.IsSamePtr() for the same address, ssa.Disjoint() for addresses that do not overlap. The rule forwarding a stored value to a load already exists with three variants looking through several other stores. Here are the two we need:

(Load <t1> p1 (Store {t2} p2 x _))
    && ssa.IsSamePtr(p1, p2)
    && copyCompatibleType(t1, x.Type)
    && t1.Size() == t2.Size()
    => x
(Load <t1> p1 (Store {t2} p2 _ (Store {t3} p3 x _)))
    && ssa.IsSamePtr(p1, p3)
    && copyCompatibleType(t1, x.Type)
    && t1.Size() == t3.Size()
    && ssa.Disjoint(p3, t3, p2, t2)
    => x

Goβ€―1.27 added the rule loading through a move with CLβ€―748200 to fix issueΒ #77720:

(Load <t1> op1:(OffPtr [o1] p1) move:(Move [n] p2 src mem))
    && o1 >= 0 && o1+t1.Size() <= n && ssa.IsSamePtr(p1, p2)
    && !ssa.IsVolatile(src)
    => @move.Block (Load <t1> (OffPtr <op1.Type> [o1] src) mem)

It lacks a variant without an offset:

(Load <t1> p1 move:(Move [n] p2 src mem))
    && p1.Op != ssaop.OpOffPtr
    && t1.Size() <= n && ssa.IsSamePtr(p1, p2)
    && !ssa.IsVolatile(src)
    => @move.Block (Load <t1> (OffPtr <p1.Type> [0] src) mem)

There is no generic rule to cancel two byte swaps, but the AMD64 lowering pass includes this rule:

(BSWAP(Q|L) (BSWAP(Q|L) p)) => p

After switching to Go’s development branch and adding the missing rule, the generated assembly code is worse than with Goβ€―1.26.8, even though our additional rule slightly improves the situation at the end:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
; Push the stack (16 bytes):
;    0(SP) a16 [16]byte
 PUSHQ   BP
 MOVQ    SP, BP
 SUBQ    $16, SP

 CMPQ    net/netipΒ·z4(SB), CX       ; check "z" if this is an IPv4 address
 JNE     end                        ; if not, stop here

; The four forwarded bytes: the low half of input.addr.lo is taken apart and
; put back together in registers
 MOVQ    BX, DX                     ; DX = input.addr.lo
 SHRQ    $24, BX                    ; BX = input.addr.lo >> 24
 MOVQ    DX, SI                     ; SI = input.addr.lo
 SHRQ    $16, DX                    ; DX = input.addr.lo >> 16
 MOVQ    SI, DI                     ; DI = input.addr.lo, kept for the pack
 SHRQ    $8, SI                     ; SI = input.addr.lo >> 8
 MOVBLZX DIB, R8                    ; R8 = byte(input.addr.lo)
 MOVBLZX SIB, SI                    ; SI = byte(input.addr.lo >> 8)
 SHLQ    $8, SI
 ORQ     R8, SI                     ; SI = two low bytes of input.addr.lo
 MOVBLZX DL, DX                     ; DX = byte(input.addr.lo >> 16)
 SHLQ    $16, DX
 ORQ     SI, DX
 MOVBLZX BL, BX                     ; BX = byte(input.addr.lo >> 24)
 SHLQ    $24, BX
 ORQ     DX, BX                     ; BX = input.addr.lo & 0xffffffff

; Pack: byteorder.BEPutUint64(a16[:8], input.addr.hi)
;       byteorder.BEPutUint64(a16[8:], input.addr.lo)
 MOVBEQ  AX, net/netipΒ·a16(SP)
 MOVBEQ  DI, net/netipΒ·a16+8(SP)

; The four other bytes of input.addr.lo, read one by one from a16
 MOVBLZX net/netipΒ·a16+11(SP), DX   ; a16[11]
 SHLQ    $32, DX
 ORQ     DX, BX
 MOVBLZX net/netipΒ·a16+10(SP), DX   ; a16[10]
 SHLQ    $40, DX
 ORQ     DX, BX
 MOVBLZX net/netipΒ·a16+9(SP), DX    ; a16[9]
 SHLQ    $48, DX
 ORQ     DX, BX
 MOVBLZX net/netipΒ·a16+8(SP), DX    ; a16[8]
 SHLQ    $56, DX

; output.z = netip.z6noz
 MOVQ    net/netipΒ·z6noz(SB), CX
; output.addr.hi = byteorder.BEUint64(a16[:8])
 MOVBEQ  net/netipΒ·a16(SP), AX
; output.addr.lo assembled from the previous steps
 ORQ     DX, BX

end:
 LEAVEQ
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

The rule loading through a move, added in Goβ€―1.27, introduced this regression.

Out of order

Let’s not give up now! In reality, the rewriting rules run before memcombine, notably in the late opt pass. At this point, the inlined versions of BEPutUint64() and BEUint64() still expand to sixteen byte stores and sixteen byte loads, matching their source code:

func BEUint64(b []byte) uint64 {
    _ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
    return uint64(b[7]) | uint64(b[6])<<8 | uint64(b[5])<<16 | uint64(b[4])<<24 |
        uint64(b[3])<<32 | uint64(b[2])<<40 | uint64(b[1])<<48 | uint64(b[0])<<56
}

Let’s follow two bytes of output.addr.lo: addr[15] and addr[11]. Here is a simplified SSA form before late opt:

  v273 = Trunc64to8 <byte> v490                     ; byte(input.addr.lo)
  v226 = Trunc64to8 <byte> v225                     ; byte(input.addr.lo >> 32)
; […]
  v235 = Store <mem> {byte} v233 v226 v223          ; a16[11] = byte(lo >> 32)
  v247 = Store <mem> {byte} v245 v238 v235          ; a16[12] = …
  v259 = Store <mem> {byte} v257 v250 v247          ; a16[13] = …
  v271 = Store <mem> {byte} v269 v262 v259          ; a16[14] = …
  v282 = Store <mem> {byte} v280 v273 v271          ; a16[15] = byte(lo)
  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
; […]
  v433 = OffPtr <*byte> [15] v285                   ; &addr[15]
  v435 = Load <byte> v433 v286                      ; addr[15]
  v479 = OffPtr <*byte> [11] v285                   ; &addr[11]
  v481 = Load <byte> v479 v286                      ; addr[11]

The first rule loads through the move: (Load (OffPtr [o] p) (Move p src mem)) => (Load (OffPtr [o] src) mem). It matches both loads, which now read a16 with the memory state before the copy:

  v600 = OffPtr <*byte> [15] v22 ; &a16[15]
  v435 = Load <byte> v600 v282   ; a16[15]
  v601 = OffPtr <*byte> [11] v22 ; &a16[11]
  v481 = Load <byte> v601 v282   ; a16[11]

The second rule shortcuts a load following a store: (Load p (Store p x _)) => x. It matches v435, as v282 stores a16[15]. It does not match v481: v235 stores a16[11] four stores earlier in the chain, while the variants of this rule look through three stores at most.

  v435 = Copy <byte> v273        ; byte(input.addr.lo)
  v601 = OffPtr <*byte> [11] v22 ; &a16[11]
  v481 = Load <byte> v601 v282   ; a16[11]

The same happens to the other bytes: the rule forwards the four bytes stored last, a16[12] to a16[15]. The twelve other loads now read a16 instead of addr.

BEUint64() becomes a chain of Or64, each one adding a byte shifted into place. memcombine is a pass written in Go, not a set of rewrite rules. It starts from the last Or64 of the chain and collects up to eight terms. If each term is a byte load, extended to 64 bits and shifted, and if the eight loads read consecutive addresses from the same pointer with the same memory state, it replaces the whole chain with a single 64-bit load and a byte swap. Otherwise, it tries again with four, then two terms, and from each intermediate Or64. Here is the loop checking each term in a simplified version of combineLoads():

for i := int64(0); i < n; i++ {
    v := a[i]
    shift := int64(0)
    if v.Op == shiftOp {
        v, shift = peelShift(v)
    }
    if v.Op != extOp {
        return false
    }
    load := v.Args[0]
    if load.Op != ssaop.OpLoad {
        return false
    }
    if load.Args[1] != mem {
        return false
    }
    p, off := splitPtr(load.Args[0])
    if p != base {
        return false
    }
    r[i] = LoadRecord{load: load, offset: off, shift: shift}
}

For output.addr.hi, the eight loads read a16 with the same memory state v282:

  v13  = Load <byte> v22 v282               ; a16[0]
  v530 = Load <byte> v14 v282               ; a16[1]
  v488 = Load <byte> v504 v282              ; a16[2]
  v405 = Load <byte> v537 v282              ; a16[3]
  v385 = Load <byte> v397 v282              ; a16[4]
  v361 = Load <byte> v373 v282              ; a16[5]
  v196 = Load <byte> v63 v282               ; a16[6]
  v432 = Load <byte> v315 v282              ; a16[7]
  v319 = ZeroExt8to64 <uint64> v432         ; uint64(a16[7])
  v329 = ZeroExt8to64 <uint64> v196         ; uint64(a16[6])
  v330 = Lsh64x64 <uint64> [true] v329 v138 ; uint64(a16[6]) << 8
  v331 = Or64 <uint64> v319 v330            ; a16[7] | a16[6] << 8
; […] same for a16[5] to a16[1]
  v401 = ZeroExt8to64 <uint64> v13          ; uint64(a16[0])
  v402 = Lsh64x64 <uint64> [true] v401 v55  ; uint64(a16[0]) << 56
  v403 = Or64 <uint64> v402 v391            ; | a16[0] << 56 = output.addr.hi

memcombine merges them into one load and a swap:

  v286 = Load <uint64> v22 v282   ; a16[:8]
  v285 = Bswap64 <uint64> v286    ; output.addr.hi

For output.addr.lo, here is the chain memcombine sees after late opt:

  v436 = ZeroExt8to64 <uint64> v273 ; addr[15], forwarded
  v448 = Or64 <uint64> v436 v447    ; | addr[14] << 8, forwarded
  v460 = Or64 <uint64> v459 v448    ; | addr[13] << 16, forwarded
  v472 = Or64 <uint64> v471 v460    ; | addr[12] << 24, forwarded
  v484 = Or64 <uint64> v483 v472    ; | a16[11] << 32, loaded
  v496 = Or64 <uint64> v495 v484    ; | a16[10] << 40, loaded
  v508 = Or64 <uint64> v507 v496    ; | a16[9] << 48, loaded
  v520 = Or64 <uint64> v519 v508    ; | a16[8] << 56, loaded

From v520, four of the eight terms are forwarded bytes, not loads from memory, and memcombine can’t combine them. It doesn’t merge the four remaining loads either, as they sit on top of the forwarded bytes.

Back in order

In summary, the rewriting rules run too early to be effective. A quick workaround exists: run an earlier round of memcombine before late opt. After this change, the generated code for the helper is back to the shortest possible version:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ    net/netipΒ·z4(SB), CX     ; check "z" if this is an IPv4 address
 JNE     end                      ; if not, stop here
 MOVQ    net/netipΒ·z6noz(SB), CX  ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

And the benchmark confirms it! ✌️

goos: linux
goarch: amd64
pkg: github.com/vincentbernat/go-netip-addrto6
cpu: AMD Ryzen 5 5600X 6-Core Processor
                   β”‚   Go 1.26.8    β”‚             Our branch              β”‚
                   β”‚     sec/op     β”‚    sec/op     vs base               β”‚
AddrTo6/safe          6.5470n Β±  0%   0.8944n Β± 4%  -86.34% (p=0.002 n=6)
AddrTo6/unsafe        0.9071n Β±  3%   0.8682n Β± 1%   -4.28% (p=0.002 n=6)
AddrTo6/builtin       0.8871n Β±  2%   0.8785n Β± 1%        ~ (p=0.310 n=6)

Next steps

I think Go maintainers would reject this change because of the additional memcombine pass. Instead, I plan to publish this blog post and bring up the subject again as a follow-up to issue #54365. Either the sheer complexity and the Goβ€―1.27 regression convince the maintainers that adding a To6() method is simpler and more efficient, or they advise me on how to move forward. Either way, digging into this subject taught me a lot about the Go compiler! βš™οΈ

Update (2026-10)

I opened issue #81994 to propose Addr.To6(). Give it a πŸ‘ if you want it in Go!


  1. IPv4-mapped IPv6 addresses let you handle only IPv6 addresses in your code and keep conversions at a few well-defined boundaries. I use them in Akvorado. ↩

  2. This is not strictly equivalent. The zero value becomes :: while it would make more sense to leave it untouched. ↩

  3. I suppose Go maintainers keep it inconvenient on purpose: they don’t want random packages to patch the standard library or other packages. ↩

  4. To avoid catastrophic bugs when netip.Addr’s layout changes, you must add tests to detect it. This is more dangerous if you put this code in a package. Either ask users to run the tests themselves, or detect the change at run time and panic. Hiding this optimization behind a build tag would make users aware of this potential trap. ↩

  5. I produced the assembly code with GOTOOLCHAIN=go1.26.8 GOAMD64=v3 ./go-asm '\.asm' from the companion repository, then edited it a bit to keep this article from turning into an endless rabbit hole. Due to an unfortunate sequence of events, we switch between versions: Goβ€―1.27 introduced a regression that blurs the point of this article. ↩

  6. The function is short enough for the compiler to inline it, so the assembly code may vary depending on the surrounding code. ↩

  7. MOVBEQ requires GOAMD64=v3, matching the x86-64-v3 microarchitecture from 2013. Otherwise, the compiler translates BEPutUint64() to BSWAPQ+MOVQ and BEUint64() to MOVQ+BSWAPQ. ↩

  8. When the call is used as a statement, a struct literal alone is invalid. The patch then turns it into _ = netip.Addr{…}. ↩

  9. The compiler can also write an HTML file with the result of each pass:

    $ GOSSAFUNC=AddrTo6Safe \
    > GOTOOLCHAIN=go1.26.8 \
    > GOAMD64=v3 go build -a .
    dumped SSA for AddrTo6Safe,1 to ./ssa.html
    

    ↩

  10. Source line numbers in parentheses follow value identifiers, but I removed them from the examples. The SSA form also features branches, but we don’t need them to understand our optimizations. ↩

  •  

Vincent Bernat: Hacking the Go compiler to efficiently map IPv4 to IPv6

netip.Addr features an Unmap() method returning the unwrapped IPv4 contained in an IPv4-mapped IPv6 address: from ::ffff:203.0.113.10 or ::ffff:cb00:710a, it returns 203.0.113.10.1 There is no Map() or To6() method for the reverse direction. Such a method is trivial to implement, but Go maintainers have rejected it on the grounds that users should write netip.AddrFrom16(ip.As16()) and let the compiler optimize it.2 Today, this pattern is eight times slower than a native method. How can we teach the compiler to optimize this sequence?

The alternatives

Let’s explore three ways to implement the map semantics for netip.Addr. My favorite is to add it to the Go standard library. Go maintainers prefer a small external helper chaining netip.AddrFrom16() and netip.Addr.As16(), hoping the compiler eventually optimizes it. The unsafe package opens a third path, with the same performance as the first solution.

Modifying the Go standard library

Internally, netip.Addr stores any IP address as a 128-bit value with an extra field z to encode the family and the zone:

type Addr struct {
    addr uint128
    z unique.Handle[addrDetail]
}

type addrDetail struct {
    isV6   bool   // IPv4 is false, IPv6 is true.
    zoneV6 string // != "" only if IsV6 is true.
}

var (
    z0    unique.Handle[addrDetail]
    z4    = unique.Make(addrDetail{})
    z6noz = unique.Make(addrDetail{isV6: true})
)

AddrFrom4() encodes an IPv4 address as an IPv4-mapped IPv6 address and sets z to the unique value z4:

// AddrFrom4 returns the address of the IPv4 address given by the bytes in addr.
func AddrFrom4(addr [4]byte) Addr {
    return Addr{
        addr: uint128{
            0,
            0xffff00000000 |
                uint64(addr[0])<<24 | uint64(addr[1])<<16 |
                uint64(addr[2])<<8 | uint64(addr[3])},
        z: z4,
    }
}

Unmap() turns an IPv4-mapped IPv6 address into an IPv4 address by setting the z field to z4:

func (ip Addr) Unmap() Addr {
    if ip.Is4In6() {
        ip.z = z4
    }
    return ip
}

Implementing the reverse direction inside the Go standard library is trivial: we set the z field to z6noz if the address is IPv4.

// To6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func (ip Addr) To6() Addr {
    if ip.Is4() {
        ip.z = z6noz
    }
    return ip
}

As a helper

We can’t access the z field from outside the net/netip package. Instead, we build a small helper around the netip.AddrFrom16(ip.As16()) pattern:

// AddrTo6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func AddrTo6(ip netip.Addr) netip.Addr {
    if ip.Is4() {
        ip = netip.AddrFrom16(ip.As16())
    }
    return ip
}

As an unsafe function

Another solution uses the unsafe package to alter the Addr struct through a proxy with the same memory layout:3

// addrProxy has the same memory layout as netip.Addr.
type addrProxy struct {
    addr [2]uint64      // netip.uint128
    z    unsafe.Pointer // unique.Handle[netip.addrDetail]
}

var (
    anyIPv6    = netip.IPv6Unspecified()
    netipZ6noz = (*addrProxy)(unsafe.Pointer(&anyIPv6)).z
)

// AddrTo6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func AddrTo6(ip netip.Addr) netip.Addr {
    if !ip.Is4() {
        return ip
    }
    (*addrProxy)(unsafe.Pointer(&ip)).z = netipZ6noz
    return ip
}

Benchmarks

On my computer, with Goβ€―1.27.1, the standard library solution costs 0.88 ns per operation, while the solution favored by Go maintainers costs 7.14 ns. The unsafe solution matches the performance of the first one.

goos: linux
goarch: amd64
pkg: github.com/vincentbernat/go-netip-addrto6
cpu: AMD Ryzen 5 5600X 6-Core Processor
                   β”‚     sec/op     β”‚
AddrTo6/safe            7.137n Β± 0%
AddrTo6/unsafe         0.8682n Β± 2%
AddrTo6/builtin        0.8775n Β± 2%

Assembly code

Let’s check the assembly code the compiler generates for each solution.4 The one built into the standard library looks like this:5

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ  net/netipΒ·z4(SB), CX     ; check "z" if this is an IPv4 address
 JNE   end                      ; if not, stop here
 MOVQ  net/netipΒ·z6noz(SB), CX  ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

Go’s assembly language is not a direct representation of the underlying machine language: it operates on a semi-abstract instruction set derived from Planβ€―9’s assembler. It has four pseudo-registers: FP (frame pointer for function arguments), PC (program counter), SB (static base pointer for global symbols), and SP (stack pointer). It also has architecture-specific registers like AX, CX, DX, BX, SI, DI, and R8 to R15. Instructions storing data use their last argument as the destination. Instructions can carry an explicit size suffix: MOVB moves a byte, MOVWβ€―16 bits, MOVLβ€―32 bits, and MOVQβ€―64 bits. In the example above, the first instruction compares the 64-bit value z4 with the CX register.

The unsafe solution looks almost the same:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ  net/netipΒ·z4(SB), CX  ; check "z" if this is an IPv4 address
 JNE   end                   ; if not, stop here
 MOVQ  netipZ6noz(SB), CX    ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

The helper solution has far more instructions. To understand why, let’s look at the code for As16() and AddrFrom16(). They are short enough for the compiler to inline them.

func (ip Addr) As16() (a16 [16]byte) {
    byteorder.BEPutUint64(a16[:8], ip.addr.hi)
    byteorder.BEPutUint64(a16[8:], ip.addr.lo)
    return a16
}

func AddrFrom16(addr [16]byte) Addr {
    return Addr{
        addr: uint128{
            byteorder.BEUint64(addr[:8]),
            byteorder.BEUint64(addr[8:]),
        },
        z: z6noz,
    }
}

We can already guess the pattern to optimize: the code packs the IP address into an array, copies it, then unpacks it. If we inline the Go code by hand, we get:

func AddrTo6(input netip.Addr) netip.Addr {
    if !input.Is4() {
        return input
    }

    var a16 [16]byte
    byteorder.BEPutUint64(a16[:8], input.addr.hi)
    byteorder.BEPutUint64(a16[8:], input.addr.lo)

    addr := a16

    var output netip.Addr
    output.addr.hi = byteorder.BEUint64(addr[:8])
    output.addr.lo = byteorder.BEUint64(addr[8:])
    output.z = netip.z6noz
    return output
}

As humans, we can mentally derive the optimized form:

func AddrTo6(input netip.Addr) netip.Addr {
    if !input.Is4() {
        return input
    }
    var output netip.Addr
    output.addr.hi = input.addr.hi
    output.addr.lo = input.addr.lo
    output.z = netip.z6noz
    return output
}

Unfortunately, as of Goβ€―1.26.8, the compiler is not smart enough to do the same:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
; Push the stack (32 bytes):
;    0(SP) addr netip.uint128
;   16(SP) a16 [16]byte
 PUSHQ   BP
 MOVQ    SP, BP
 SUBQ    $32, SP

 CMPQ    net/netipΒ·z4(SB), CX  ; check "z" if this is an IPv4 address
 JNE     end                   ; if not, stop here

; Pack: byteorder.BEPutUint64(a16[:8], input.addr.hi)
;       byteorder.BEPutUint64(a16[8:], input.addr.lo)
 MOVBEQ  AX, net/netipΒ·a16+16(SP)
 MOVBEQ  BX, net/netipΒ·a16+24(SP)

; addr = a16, 16 bytes at once through the vector register X0
 MOVUPS  net/netipΒ·a16+16(SP), X0
 MOVUPS  X0, net/netipΒ·addr(SP)

; CX = netip.z6noz
 MOVQ    net/netipΒ·z6noz(SB), CX
; Unpack: output.addr.hi = byteorder.BEUint64(addr[:8])
;         output.addr.lo = byteorder.BEUint64(addr[8:])
 MOVBEQ  net/netipΒ·addr(SP), AX
 MOVBEQ  net/netipΒ·addr+8(SP), BX

end:
 ADDQ    $32, SP
 POPQ    BP
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

The compiler does a decent job on the byte shuffling: the eight byte stores of BEPutUint64() become a single MOVBEQ, which stores a register byte-swapped. The eight byte loads of BEUint64() become a single MOVBEQ the other way round.6 Three groups of instructions remain: a pack, a copy, and an unpack.

Hacking the Go compiler

The Go compiler has several phases:

Parsing
The compiler tokenizes and parses the source code. It builds a syntax tree for each source file.
Type checking
The compiler maps each identifier to the object it denotes, folds constants, and infers the type of every expression.
IR construction
The compiler converts the syntax tree and its types into its own intermediate representation (IR). This process, called β€œnoding,” goes through a serialization format named unified IR.
Middle end
The compiler performs several optimization passes on the IR, such as devirtualization, function call inlining, and escape analysis.
Walk
This phase runs two steps: order of evaluation decomposes complex statements into simpler ones, and desugaring transforms higher-level Go constructs, like switch or channels, into more primitive instructions or calls to the runtime.
Generic SSA
The compiler converts the IR into Static Single Assignment (SSA) form, a lower-level intermediate representation suited for machine-independent optimizations and rewrite rules.
Machine code generation
The compiler rewrites the SSA form into machine-specific variants, allocates registers, and applies more optimization passes. At the end, the assembler turns the generated instructions into machine code.

The hammer

My first idea is to replace occurrences of netip.AddrFrom16(ip.As16()) with netip.Addr{addr: ip.addr, z: netip.z6noz} as early as possible, during the β€œnoding” process. Before that, the type checking phase prevents us from accessing unexported struct fields.

Goβ€―1.27 introduced a convenient debug option to dump the IR of a function at interesting points during compilation:

$ GOTOOLCHAIN=go1.27.1 GOAMD64=v3 go build -a -gcflags="-d=astdump=AddrTo6Safe" .
Writing text ast output for AddrTo6Safe to AddrTo6Safe.ast
Writing html ast output for AddrTo6Safe to AddrTo6Safe.html
Writing html syntax output for AddrTo6Safe to AddrTo6Safe.syntax.html

In the HTML file, the first column shows the IR as it comes out of noding:

DCLFUNC addrto6.AddrTo6Safe ABI:ABIInternal FUNC-func(netip.Addr) netip.Addr
DCLFUNC-Dcl
. NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. NAME-addrto6.~r0 Class:PPARAMOUT Offset:0 OnStack netip.Addr
DCLFUNC-body
. IF # ipv6_safe.go:11:2
. IF-Cond
. . CALLFUNC bool
. . CALLFUNC-Fun
. . . METHEXPR addrto6.Is4 FUNC-func(netip.Addr) bool
. . . . TYPE netip.Addr Class:PEXTERN Offset:0 type netip.Addr
. . CALLFUNC-Args
. . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. IF-Body
. . AS # ipv6_safe.go:12:6
. . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . . CALLFUNC netip.Addr
. . . CALLFUNC-Fun
. . . . NAME-netip.AddrFrom16 Class:PFUNC Offset:0 Used FUNC-func([16]byte) netip.Addr
. . . CALLFUNC-Args
. . . . CALLFUNC ARRAY-[16]byte
. . . . CALLFUNC-Fun
. . . . . METHEXPR addrto6.As16 FUNC-func(netip.Addr) [16]byte
. . . . . . TYPE netip.Addr Class:PEXTERN Offset:0 type netip.Addr
. . . . CALLFUNC-Args
. . . . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. RETURN # ipv6_safe.go:14:2
. RETURN-Results
. . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr

In the body of the if statement, we spot the calls to the method netip.Addr.As16() and to the function netip.AddrFrom16(). Our goal is to patch them with a struct literal:

IF-Body
. AS # ipv6_safe.go:12:6
. . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . STRUCTLIT netip.Addr
. . STRUCTLIT-List
. . . STRUCTKEY netip.addr
. . . . DOT netip.addr netip.uint128
. . . . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . . STRUCTKEY netip.z
. . . . NAME-netip.z6noz Class:PEXTERN Offset:0 unique.Handle[net/netip.addrDetail]

In noder’s reader.go, the expr() method builds the IR tree for an expression. At the end of the exprCall case, we add a call to a rewriteAddrFrom16As16() function. It takes the current node and returns the struct literal on success, or nil if the rewrite is not possible. First, we check that we have the expected pattern: a call to the netip.AddrFrom16() function with a call to the netip.Addr.As16() method as its only argument:

func rewriteAddrFrom16As16(n ir.Node) ir.Node {
    call, ok := n.(*ir.CallExpr)
    if !ok || call.Op() != ir.OCALLFUNC ||
        len(call.Args) != 1 || len(call.Init()) != 0 ||
        !isNetipFunc(call.Fun, "AddrFrom16") {
        return nil
    }
    inner, ok := call.Args[0].(*ir.CallExpr)
    if !ok || inner.Op() != ir.OCALLFUNC ||
        len(inner.Args) != 1 || len(inner.Init()) != 0 ||
        !isNetipFunc(inner.Fun, "Addr.As16") {
        return nil
    }
    x := inner.Args[0]
    // [...]
}

Then, we fetch netip.z6noz:

z6noz, err := lookupVar(ir.StaticCalleeName(call.Fun).Sym().Pkg, "z6noz")
if err != nil {
    return nil
}

And we build the struct literal:

typ := call.Type()
pos := call.Pos()
var list []ir.Node
for i, f := range typ.Fields() {
    var value ir.Node
    switch f.Sym.Name {
    case "addr":
        value = typecheck.DotField(pos, x, i)
    case "z":
        value = z6noz
    default:
        return nil
    }
    list = append(list, ir.NewStructKeyExpr(pos, f, value))
}
lit := ir.NewCompLitExpr(pos, ir.OSTRUCTLIT, typ, list)
lit.SetTypecheck(1)
return lit

Have a look at the complete patch.7 We can test it with the following commands:

$ cd src
$ ./make.bash
Building Go cmd/dist using /usr/lib/go-1.27. (go1.27.1 linux/amd64)
Building Go toolchain1 and bootstrap cmd/go (go_bootstrap) using /usr/lib/go-1.27.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 and commands using go_bootstrap and Go toolchain2.
Checking command staleness for linux/amd64.
---
Installed Go for linux/amd64 in /home/bernat/code/free/go
Installed commands in /home/bernat/code/free/go/bin
*** You need to add /home/bernat/code/free/go/bin to your PATH.
$ export PATH=$PWD/../bin:$PATH
$ go version
go version go1.28-devel_9834516e20 Sat Sep 12 08:23:11 2026 -0700 linux/amd64
$ go test net/netip/...
ok      net/netip   0.224s
$ cd ../../go-netip-addrto6
$ go test .
ok      github.com/vincentbernat/go-netip-addrto6   0.062s

The generated code for the helper is now the shortest possible version!

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ    net/netipΒ·z4(SB), CX     ; check "z" if this is an IPv4 address
 JNE     end                      ; if not, stop here
 MOVQ    net/netipΒ·z6noz(SB), CX  ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

Go maintainers are unlikely to accept this change. It relies on the internal structure of net/netip.Addr. It’s an ugly hack in the noder, whose job is to faithfully translate the type-checked AST into the IR. And it’s harder to maintain than adding a To6() method.

The screwdriver

The right place for such an optimization is the generic SSA phase. One of the last machine-independent passes is memcombine. With the appropriate debug flag, the compiler dumps the SSA form after this pass:8

$ GOTOOLCHAIN=go1.26.8 GOAMD64=v3 \
> go build -a -gcflags='-d=ssa/memcombine/dump=AddrTo6Safe' .
$ head -5 AddrTo6Safe_01__memcombine.dump
AddrTo6Safe func(netip.Addr) netip.Addr
  b2:
    (?) v1 = InitMem <mem>
    (?) v2 = SP <uintptr>
    (?) v3 = SB <uintptr>

The result of the memcombine pass follows the same structure as the assembly code for AddrTo6Safe() we looked at earlier: two stores, one move, and two loads we would like to optimize away.

; […]
  v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
  v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
  v466 = ArgIntReg <*netip.addrDetail> {ip+16} [2]  ; input.z
; […]
  v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
  v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
  v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
  v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
  v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
  v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)

  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16

  v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
  v416 = Load <uint64> v285 v286                    ; addr[:8]
  v299 = Bswap64 <uint64> v416                      ; output.addr.hi
  v174 = Load <uint64> v415 v286                    ; addr[8:]
  v39  = Bswap64 <uint64> v174                      ; output.addr.lo
; […]

Each line features a value identifier (v442), an operation with its type (Bswap64 <uint64>), and its arguments (v490).9 Values are the basic building blocks of SSA and are defined exactly once. Square brackets enclose integer parameters ([8]) and curly braces contain auxiliary arguments ({netip.addr}). Operations writing to memory produce a new memory state. Every memory operation takes the current state as its last argument, which keeps them in order.

On paper

Let’s focus on output.addr.lo, aka v39:

  v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
  v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
  v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
  v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
  v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
  v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
  v174 = Load <uint64> v415 v286                    ; addr[8:]
  v39  = Bswap64 <uint64> v174                      ; output.addr.lo

To simplify this code, we could apply three rewriting rules:

  1. The first one adds a shortcut when loading through a move: (Load (OffPtr [o] p) (Move p src mem)) => (Load (OffPtr [o] src) mem). This matches v174 with its arguments v415 and v286 and creates a new value v600:

      v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
      v600 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v174 = Load <uint64> v600 v282                    ; a16[8:]
      v39  = Bswap64 <uint64> v174                      ; output.addr.lo
    
  2. The second one simplifies a load following a store: (Load p (Store p x _)) => x. The load is forwarded: the stored value replaces it and no memory access remains. It matches v174. It notices that v600 and v173 are the same address and replaces v174 with a copy of v442:

      v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
      v600 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v174 = Copy <uint64> v442                         ; bswap(input.addr.lo)
      v39  = Bswap64 <uint64> v174                      ; output.addr.lo
    
  3. The last step cancels the two byte swaps: (Bswap64 (Bswap64 x)) => x. v39 becomes a copy of v490:

      v490 = ArgIntReg <uint64> {ip+8} [1]              ; input.addr.lo
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v442 = Bswap64 <uint64> v490                      ; bswap(input.addr.lo)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v415 = OffPtr <*byte> [8] v285                    ; &addr[8]
      v600 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v174 = Copy <uint64> v442                         ; bswap(input.addr.lo)
      v39  = Copy <uint64> v490                         ; output.addr.lo = input.addr.lo
    

If we ignore the values not needed to compute v39, only this SSA form remains:

  v490 = ArgIntReg <uint64> {ip+8} [1]  ; input.addr.lo
  v39  = Copy <uint64> v490             ; output.addr.lo = input.addr.lo

Let’s switch to output.addr.hi, aka v299:

  v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
  v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
  v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
  v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
  v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
  v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
  v416 = Load <uint64> v285 v286                    ; addr[:8]
  v299 = Bswap64 <uint64> v416                      ; output.addr.hi

To optimize it away, we also apply three rewriting rules:

  1. The first one also adds a shortcut when loading through a move, but without an offset: (Load p (Move p src mem)) => (Load src mem). This rewrites v416 to use arguments from v286:

      v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
      v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v416 = Load <uint64> v22 v282                     ; a16[:8]
      v299 = Bswap64 <uint64> v416                      ; output.addr.hi
    
  2. The second rule forwards a value stored one step earlier, skipping over a store to another address: (Load p (Store q _ (Store p x _))) => x. This matches v416: x is v542, p is v22 (&a16), q is v173 (&a16[8]), and p and q do not overlap for uint64.

      v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
      v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v416 = Copy <uint64> v542                         ; bswap(input.addr.hi)
      v299 = Bswap64 <uint64> v416                      ; output.addr.hi
    
  3. The third rule cancels two byte swaps: (Bswap64 (Bswap64 x)) => x. v299 becomes a copy of v502:

      v502 = ArgIntReg <uint64> {ip+0} [0]              ; input.addr.hi
      v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1     ; &a16
      v173 = OffPtr <*byte> [8] v22                     ; &a16[8]
      v542 = Bswap64 <uint64> v502                      ; bswap(input.addr.hi)
      v161 = Store <mem> {uint64} v22 v542 v23          ; a16[:8] = bswap(hi)
      v282 = Store <mem> {uint64} v173 v442 v161        ; a16[8:] = bswap(lo)
      v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
      v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
      v416 = Copy <uint64> v542                         ; bswap(input.addr.hi)
      v299 = Copy <uint64> v502                         ; output.addr.hi = input.addr.hi
    

If we remove the values not used to compute v299, we get this SSA form:

  v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi
  v299 = Copy <uint64> v502            ; output.addr.hi = input.addr.hi

In practice

Most of these rules already exist in generic.rules. They use conditions to validate their context: ssa.IsSamePtr() for the same address, ssa.Disjoint() for addresses that do not overlap. The rule forwarding a stored value to a load already exists with three variants looking through several other stores. Here are the two we need:

(Load <t1> p1 (Store {t2} p2 x _))
    && ssa.IsSamePtr(p1, p2)
    && copyCompatibleType(t1, x.Type)
    && t1.Size() == t2.Size()
    => x
(Load <t1> p1 (Store {t2} p2 _ (Store {t3} p3 x _)))
    && ssa.IsSamePtr(p1, p3)
    && copyCompatibleType(t1, x.Type)
    && t1.Size() == t3.Size()
    && ssa.Disjoint(p3, t3, p2, t2)
    => x

Goβ€―1.27 added the rule loading through a move with CLβ€―748200 to fix issueΒ #77720:

(Load <t1> op1:(OffPtr [o1] p1) move:(Move [n] p2 src mem))
    && o1 >= 0 && o1+t1.Size() <= n && ssa.IsSamePtr(p1, p2)
    && !ssa.IsVolatile(src)
    => @move.Block (Load <t1> (OffPtr <op1.Type> [o1] src) mem)

It lacks a variant without an offset:

(Load <t1> p1 move:(Move [n] p2 src mem))
    && p1.Op != ssaop.OpOffPtr
    && t1.Size() <= n && ssa.IsSamePtr(p1, p2)
    && !ssa.IsVolatile(src)
    => @move.Block (Load <t1> (OffPtr <p1.Type> [0] src) mem)

There is no generic rule to cancel two byte swaps, but the AMD64 lowering pass includes this rule:

(BSWAP(Q|L) (BSWAP(Q|L) p)) => p

After switching to Go’s development branch and adding the missing rule, the generated assembly code is worse than with Goβ€―1.26.8, even though our additional rule slightly improves the situation at the end:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
; Push the stack (16 bytes):
;    0(SP) a16 [16]byte
 PUSHQ   BP
 MOVQ    SP, BP
 SUBQ    $16, SP

 CMPQ    net/netipΒ·z4(SB), CX       ; check "z" if this is an IPv4 address
 JNE     end                        ; if not, stop here

; The four forwarded bytes: the low half of input.addr.lo is taken apart and
; put back together in registers
 MOVQ    BX, DX                     ; DX = input.addr.lo
 SHRQ    $24, BX                    ; BX = input.addr.lo >> 24
 MOVQ    DX, SI                     ; SI = input.addr.lo
 SHRQ    $16, DX                    ; DX = input.addr.lo >> 16
 MOVQ    SI, DI                     ; DI = input.addr.lo, kept for the pack
 SHRQ    $8, SI                     ; SI = input.addr.lo >> 8
 MOVBLZX DIB, R8                    ; R8 = byte(input.addr.lo)
 MOVBLZX SIB, SI                    ; SI = byte(input.addr.lo >> 8)
 SHLQ    $8, SI
 ORQ     R8, SI                     ; SI = two low bytes of input.addr.lo
 MOVBLZX DL, DX                     ; DX = byte(input.addr.lo >> 16)
 SHLQ    $16, DX
 ORQ     SI, DX
 MOVBLZX BL, BX                     ; BX = byte(input.addr.lo >> 24)
 SHLQ    $24, BX
 ORQ     DX, BX                     ; BX = input.addr.lo & 0xffffffff

; Pack: byteorder.BEPutUint64(a16[:8], input.addr.hi)
;       byteorder.BEPutUint64(a16[8:], input.addr.lo)
 MOVBEQ  AX, net/netipΒ·a16(SP)
 MOVBEQ  DI, net/netipΒ·a16+8(SP)

; The four other bytes of input.addr.lo, read one by one from a16
 MOVBLZX net/netipΒ·a16+11(SP), DX   ; a16[11]
 SHLQ    $32, DX
 ORQ     DX, BX
 MOVBLZX net/netipΒ·a16+10(SP), DX   ; a16[10]
 SHLQ    $40, DX
 ORQ     DX, BX
 MOVBLZX net/netipΒ·a16+9(SP), DX    ; a16[9]
 SHLQ    $48, DX
 ORQ     DX, BX
 MOVBLZX net/netipΒ·a16+8(SP), DX    ; a16[8]
 SHLQ    $56, DX

; output.z = netip.z6noz
 MOVQ    net/netipΒ·z6noz(SB), CX
; output.addr.hi = byteorder.BEUint64(a16[:8])
 MOVBEQ  net/netipΒ·a16(SP), AX
; output.addr.lo assembled from the previous steps
 ORQ     DX, BX

end:
 LEAVEQ
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

The rule loading through a move, added in Goβ€―1.27, introduced this regression.

Out of order

Let’s not give up now! In reality, the rewriting rules run before memcombine, notably in the late opt pass. At this point, the inlined versions of BEPutUint64() and BEUint64() still expand to sixteen byte stores and sixteen byte loads, matching their source code:

func BEUint64(b []byte) uint64 {
    _ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
    return uint64(b[7]) | uint64(b[6])<<8 | uint64(b[5])<<16 | uint64(b[4])<<24 |
        uint64(b[3])<<32 | uint64(b[2])<<40 | uint64(b[1])<<48 | uint64(b[0])<<56
}

Let’s follow two bytes of output.addr.lo: addr[15] and addr[11]. Here is a simplified SSA form before late opt:

  v273 = Trunc64to8 <byte> v490                     ; byte(input.addr.lo)
  v226 = Trunc64to8 <byte> v225                     ; byte(input.addr.lo >> 32)
; […]
  v235 = Store <mem> {byte} v233 v226 v223          ; a16[11] = byte(lo >> 32)
  v247 = Store <mem> {byte} v245 v238 v235          ; a16[12] = …
  v259 = Store <mem> {byte} v257 v250 v247          ; a16[13] = …
  v271 = Store <mem> {byte} v269 v262 v259          ; a16[14] = …
  v282 = Store <mem> {byte} v280 v273 v271          ; a16[15] = byte(lo)
  v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
  v286 = Move <mem> {[16]byte} [16] v285 v22 v282   ; addr = a16
; […]
  v433 = OffPtr <*byte> [15] v285                   ; &addr[15]
  v435 = Load <byte> v433 v286                      ; addr[15]
  v479 = OffPtr <*byte> [11] v285                   ; &addr[11]
  v481 = Load <byte> v479 v286                      ; addr[11]

The first rule loads through the move: (Load (OffPtr [o] p) (Move p src mem)) => (Load (OffPtr [o] src) mem). It matches both loads, which now read a16 with the memory state before the copy:

  v600 = OffPtr <*byte> [15] v22 ; &a16[15]
  v435 = Load <byte> v600 v282   ; a16[15]
  v601 = OffPtr <*byte> [11] v22 ; &a16[11]
  v481 = Load <byte> v601 v282   ; a16[11]

The second rule shortcuts a load following a store: (Load p (Store p x _)) => x. It matches v435, as v282 stores a16[15]. It does not match v481: v235 stores a16[11] four stores earlier in the chain, while the variants of this rule look through three stores at most.

  v435 = Copy <byte> v273        ; byte(input.addr.lo)
  v601 = OffPtr <*byte> [11] v22 ; &a16[11]
  v481 = Load <byte> v601 v282   ; a16[11]

The same happens to the other bytes: the rule forwards the four bytes stored last, a16[12] to a16[15]. The twelve other loads now read a16 instead of addr.

BEUint64() becomes a chain of Or64, each one adding a byte shifted into place. memcombine is a pass written in Go, not a set of rewrite rules. It starts from the last Or64 of the chain and collects up to eight terms. If each term is a byte load, extended to 64 bits and shifted, and if the eight loads read consecutive addresses from the same pointer with the same memory state, it replaces the whole chain with a single 64-bit load and a byte swap. Otherwise, it tries again with four, then two terms, and from each intermediate Or64. Here is the loop checking each term in a simplified version of combineLoads():

for i := int64(0); i < n; i++ {
    v := a[i]
    shift := int64(0)
    if v.Op == shiftOp {
        v, shift = peelShift(v)
    }
    if v.Op != extOp {
        return false
    }
    load := v.Args[0]
    if load.Op != ssaop.OpLoad {
        return false
    }
    if load.Args[1] != mem {
        return false
    }
    p, off := splitPtr(load.Args[0])
    if p != base {
        return false
    }
    r[i] = LoadRecord{load: load, offset: off, shift: shift}
}

For output.addr.hi, the eight loads read a16 with the same memory state v282:

  v13  = Load <byte> v22 v282               ; a16[0]
  v530 = Load <byte> v14 v282               ; a16[1]
  v488 = Load <byte> v504 v282              ; a16[2]
  v405 = Load <byte> v537 v282              ; a16[3]
  v385 = Load <byte> v397 v282              ; a16[4]
  v361 = Load <byte> v373 v282              ; a16[5]
  v196 = Load <byte> v63 v282               ; a16[6]
  v432 = Load <byte> v315 v282              ; a16[7]
  v319 = ZeroExt8to64 <uint64> v432         ; uint64(a16[7])
  v329 = ZeroExt8to64 <uint64> v196         ; uint64(a16[6])
  v330 = Lsh64x64 <uint64> [true] v329 v138 ; uint64(a16[6]) << 8
  v331 = Or64 <uint64> v319 v330            ; a16[7] | a16[6] << 8
; […] same for a16[5] to a16[1]
  v401 = ZeroExt8to64 <uint64> v13          ; uint64(a16[0])
  v402 = Lsh64x64 <uint64> [true] v401 v55  ; uint64(a16[0]) << 56
  v403 = Or64 <uint64> v402 v391            ; | a16[0] << 56 = output.addr.hi

memcombine merges them into one load and a swap:

  v286 = Load <uint64> v22 v282   ; a16[:8]
  v285 = Bswap64 <uint64> v286    ; output.addr.hi

For output.addr.lo, here is the chain memcombine sees after late opt:

  v436 = ZeroExt8to64 <uint64> v273 ; addr[15], forwarded
  v448 = Or64 <uint64> v436 v447    ; | addr[14] << 8, forwarded
  v460 = Or64 <uint64> v459 v448    ; | addr[13] << 16, forwarded
  v472 = Or64 <uint64> v471 v460    ; | addr[12] << 24, forwarded
  v484 = Or64 <uint64> v483 v472    ; | a16[11] << 32, loaded
  v496 = Or64 <uint64> v495 v484    ; | a16[10] << 40, loaded
  v508 = Or64 <uint64> v507 v496    ; | a16[9] << 48, loaded
  v520 = Or64 <uint64> v519 v508    ; | a16[8] << 56, loaded

From v520, four of the eight terms are forwarded bytes, not loads from memory, and memcombine can’t combine them. It doesn’t merge the four remaining loads either, as they sit on top of the forwarded bytes.

Back in order

In summary, the rewriting rules run too early to be effective. A quick workaround exists: run an earlier round of memcombine before late opt. After this change, the generated code for the helper is back to the shortest possible version:

// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
 CMPQ    net/netipΒ·z4(SB), CX     ; check "z" if this is an IPv4 address
 JNE     end                      ; if not, stop here
 MOVQ    net/netipΒ·z6noz(SB), CX  ; CX = netip.z6noz
end:
 RET
// return value = Addr{hi: AX, lo: BX, z: CX}

And the benchmark confirms it! ✌️

goos: linux
goarch: amd64
pkg: github.com/vincentbernat/go-netip-addrto6
cpu: AMD Ryzen 5 5600X 6-Core Processor
                   β”‚   Go 1.26.8    β”‚             Our branch              β”‚
                   β”‚     sec/op     β”‚    sec/op     vs base               β”‚
AddrTo6/safe          6.5470n Β±  0%   0.8944n Β± 4%  -86.34% (p=0.002 n=6)
AddrTo6/unsafe        0.9071n Β±  3%   0.8682n Β± 1%   -4.28% (p=0.002 n=6)
AddrTo6/builtin       0.8871n Β±  2%   0.8785n Β± 1%        ~ (p=0.310 n=6)

Next steps

I think Go maintainers would reject this change because of the additional memcombine pass. Instead, I plan to publish this blog post and bring up the subject again as a follow-up to issue #54365. Either the sheer complexity and the Goβ€―1.27 regression convince the maintainers that adding a To6() method is simpler and more efficient, or they advise me on how to move forward. Either way, digging into this subject taught me a lot about the Go compiler! βš™οΈ


  1. IPv4-mapped IPv6 addresses let you handle only IPv6 addresses in your code and keep conversions at a few well-defined boundaries. I use them in Akvorado. ↩

  2. This is not strictly equivalent. The zero value becomes :: while it would make more sense to leave it untouched. ↩

  3. To avoid catastrophic bugs when netip.Addr’s layout changes, you must add tests to detect it. This is more dangerous if you put this code in a package. Either ask users to run the tests themselves, or detect the change at run time and panic. Hiding this optimization behind a build tag would make users aware of this potential trap. ↩

  4. I produced the assembly code with GOTOOLCHAIN=go1.26.8 GOAMD64=v3 ./go-asm '\.asm' from the companion repository, then edited it a bit to keep this article from turning into an endless rabbit hole. Due to an unfortunate sequence of events, we switch between versions: Goβ€―1.27 introduced a regression that blurs the point of this article. ↩

  5. The function is short enough for the compiler to inline it, so the assembly code may vary depending on the surrounding code. ↩

  6. MOVBEQ requires GOAMD64=v3, matching the x86-64-v3 microarchitecture from 2013. Otherwise, the compiler translates BEPutUint64() to BSWAPQ+MOVQ and BEUint64() to MOVQ+BSWAPQ. ↩

  7. When the call is used as a statement, a struct literal alone is invalid. The patch then turns it into _ = netip.Addr{…}. ↩

  8. The compiler can also write an HTML file with the result of each pass:

    $ GOSSAFUNC=AddrTo6Safe \
    > GOTOOLCHAIN=go1.26.8 \
    > GOAMD64=v3 go build -a .
    dumped SSA for AddrTo6Safe,1 to ./ssa.html
    

    ↩

  9. Source line numbers in parentheses follow value identifiers, but I removed them from the examples. The SSA form also features branches, but we don’t need them to understand our optimizations. ↩

  •  

Steinar H. Gunderson: Decompilation patterns part 1: Anti-CSE

There's a lot ofΒΉ interest in decompilation these days; taking some old piece of software (usually a game) and try to reconstruct C that does the same thing. (This is often for modding purposes, but also often for understanding, or just as a personal challenge.) An often sought-after property is matching; that the decompiled C produces 100% the same bytes as the original after compilation. (This means you'll need to find the original compiler and build flags, of course.) Typically, one starts with the disassembled code and runs m2c on it, which then spits out some C that is fairly close to the original, but rarely produces a match without further cleanup. (It is usually much closer to a match than what Ghidra or Hex-Rays would output; Ghidra is made for ease of understanding, not matching, so it tends to simplify a lot.)

Cleaning up the code for matching has two main advantages:

  1. It usually makes the source easier to understand, and
  2. Matching is a very strong argument (though not infallible) that the decompilation is actually correct.

Like with pretty much any other field these days, there is a lot of interest in giving this task to some LLM, which means there's now a lot of decompilations around that match (fitting #2 perfectly) and completely fail #1. :-) Anyway, at some point, I learned that there is almost no public documentation on the cleaning-up-for-matching part; it's all folklore and people rediscovering the same patterns. So I thought I'd do something about thatΒ².

My general goal is to write up one decompilation pattern a day (focusing on what I know best, namely GCC on MIPS), with some example. Most of them will be very simple; some are much less obvious. I'll keep going until I run out of ideas. None are formally named, so I'll just invent a name. So here goes part one:

  temp_v0_5 = g_state.players[1].unk2;
  if ((temp_v0_5 != 7) && (temp_v0_5 != 0)) {

What happened here is usually that the compiler has done common subexpression elimination; someone wrote the same thing twice and then the compiler decided to just calculate it once and put it into a temporary variable. (Or the programmer did, in which case you would be wise to leave it alone! Getting into the original programmer's head is part of the challenge.) This will often affect register allocation and/or stack layout, so to get a better match, you could try doing CSE in reverse:

  if (g_state.players[1].unk2 != 7 && g_state.players[1].unk2 != 0) {

Nothing is mechanical; it may help or it may not. (It may also appear to hurt at first and then help later, or vice versa.) But anti-CSE is generally very common and you'll want to have it in your arsenal.

ΒΉ Well, all is relative.

Β² Reluctantly, with the understanding that the LLMs might also learn from it.

  •  

Distribution Release: Parrot 7.4

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. Parrot is a Debian-based, security-oriented distribution featuring a collection of utilities designed for penetration testing, computer forensics, reverse engineering, hacking, privacy, anonymity and cryptography. The project's latest release, version 7.4, includes several changes, including a new onboarding tool: "A new tool has been added to our collection -....
  •  

Top Stories: Apple Smart Home Launch on October 13, iPhone Duo Production Issues, and More

Things are getting crazy in the Apple rumor world, as even though the iPhone 18 Pro and several other products have only just launched and the iPhone Duo is right around the corner, there's even more to come imminently.


October 13 looks to be the date for an introduction of Apple's revamped smart home portfolio, including the home hub device we've been hearing about for ages. This week also saw word that Apple's supply chain is struggling to meet production yield goals for the iPhone Duo ahead of its launch, while iPhone 18 Pro owners are now getting a chance to put the devices through their paces, so read on below for all of the details!

Top Stories


Three New Apple Smart Home Products Coming on October 13


A couple of those leaked products above could be arriving very shortly, as Bloomberg's Mark Gurman says Apple will be introducing a trio of new smart home products on Tuesday, October 13.


Apple will reportedly debut its long-rumored smart home hub product, as well as the new HomePod mini and Apple TV models, and while he didn't specify how Apple will introduce them, signs are pointing to some sort of event.

Apple retail stores have reportedly also received confidential boxes that are marked "Do Not Open Until October 8" and which are likely to contain merchandising materials. It's unclear, however, if they are for the upcoming iPhone Duo launch or something related to the smart home product introduction.

Apple Smart Home Hub to Feature iMac G4-Style Design


Apple's long-rumored smart home hub will have an iMac G4-style design and a host of unique features, Bloomberg's Mark Gurman reports.


The smart home hub is said to feature a square 6-inch display, with variants that can be placed on a countertop or mounted on a wall. It features a single FaceTime camera on the front, with microphones and speakers in its connected base.

Leaks suggest the hub may be available in Silver, Space Gray, Starlight, and Rose Pink colors, and will be able to display user-customizable Photo Faces pulled from your photos library, memories, and shared albums, a feature that appears similar to what will be available for StandBy mode on the iPhone Duo.

iPhone Duo Reportedly Facing Production Problems Ahead of Launch


The iPhone Duo is reportedly facing production difficulties ahead of its October launch, with early output expected to be limited.


A person said to be close to Foxconn's supply chain told China's Jiemian News that final assembly yields for Apple's first foldable iPhone were only slightly above 60% as of September 17. The source said that, under Apple's current quality standards, it could take another six months to a year for the assembly line to reach a mature yield.

According to the source, Foxconn is repeatedly adjusting equipment, assembly processes, and production settings as it makes the iPhone Duo at scale. The source also said suppliers were waiting to see whether Apple would ease any quality requirements to accelerate production.

iPhone 18 Pro First Impressions: What Do You Think So Far?


It's been almost two weeks since the iPhone 18 Pro models launched, and we want to hear from MacRumors readers. What do you think of Apple's newest iPhones? If you upgraded from an older phone, was it worth it? What about upgrading from an iPhone 17 Pro? What problems are you running into? Anything you love so far?


MacRumors readers have already been sharing early opinions on the MacRumors forums, and there are interesting discussion threads to read through. For early reviews and hands-on images, make sure to check out the first impressions and photos thread.

Not everything has been perfect, however, as some users have been experiencing issues such as speaker crackling or popping while some iPhone 18 Pro Max users on AT&T in the U.S. have been having issues with cellular connectivity or eSIM provisioning.

Apple Planning to Launch Products Faster and More Often


John Ternus is marking one month as Apple CEO, and he is apparently undertaking efforts to restructure the company to accelerate product development and introduce new products more frequently, according to Bloomberg's Mark Gurman.


Ternus apparently wants Apple to "broaden its range of devices and create a leaner organization with a greater focus on engineering." One proposal under consideration is to rely less on Apple's traditional spring and fall product release cycles, and introduce new devices more quickly and throughout the year. Ternus has also purportedly indicated that Apple must become "more experimental" to remain competitive in the era of artificial intelligence.

MacRumors Newsletter


Each week, we publish an email newsletter like this highlighting the top Apple stories, making it a great way to get a bite-sized recap of the week hitting all of the major topics we've covered and tying together related stories for a big-picture view.

So if you want to have top stories like the above recap delivered to your email inbox each week, subscribe to our newsletter!
This article, "Top Stories: Apple Smart Home Launch on October 13, iPhone Duo Production Issues, and More" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Vincent Bernat: Self-hosted HTTP tunnels with SSH and nginx

A friend wants to proofread your work-in-progress blog post, but its preview only runs on localhost:8080. Several tools can help. Some run as a commercial service, like ngrok or Cloudflare Quick Tunnels. Some are self-hostable but require a specific client, like frp or localtunnel. Some only require a plain SSH client but rely on a specific SSH server, like sish. Let’s implement a self-hosted solution with only OpenSSH and nginx!

$ ssh -R 0:localhost:8080 http-over-ssh
Allocated port 41535 for remote forward to localhost:8080
https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/

Basic setup

First, we forward connections from a port on a remote server to your local service:

$ ssh -N -R 0:localhost:8080 web02.luffy.cx
Allocated port 41535 for remote forward to localhost:8080

When you specify 0 as the remote port, the server allocates a free port. Then, we configure nginx to proxy requests from https://p41535.ssh.luffy.cx to http://127.0.0.1:41535:

server {
  listen 0.0.0.0:443 ssl ;
  listen [::0]:443 ssl ;
  server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
  location / {
    proxy_pass http://127.0.0.1:$port;
  }
}

We also need to add DNS records for *.ssh.luffy.cx and get a wildcard certificate through Let’s Encrypt:

*.ssh.luffy.cx.               CNAME web02.luffy.cx.
ssh.luffy.cx.                 CAA   0 issuewild "letsencrypt.org"
_acme-challenge.ssh.luffy.cx  CNAME ssh.luffy.cx.acme.luffy.cx.

acme.luffy.cx is a zone hosted on Routeβ€―53. I use it for ACME DNS-01 challenges, both for wildcard certificates and for domains served by several web servers. In my case, NixOS gets the certificates automatically.

Access control

The port is the only β€œsecret”1 keeping the content confidential. Other forwarding solutions add a random string to the domain name to prevent an intruder from enumerating the possible values.

Thanks to ngx_http_secure_link_module, we can secure this setup a bit. This module computes a hash2 over a set of values, including a secret, and compares it with the hash from the request. The hash is base64-encoded, so we cannot put it in the domain name, which is case-insensitive. Instead, we put it in the URL as a username, along with its expiration timestamp:3

https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog
        ╰─────────┬──────────╯  ╰───┬────╯  ╰─┬─╯             ╰──┬───╯
                hash             expires    port               path

The client sends the username to the server with HTTP basic authentication. This works with most HTTP clients, including curl. Nginx exposes the username in the $remote_user variable. The module expects the hash and the expiration timestamp separated by a comma. We use a map directive to extract the two parts from $remote_user and join them with a comma.4 We also give the module the string to hash. It contains the expiration timestamp, the port, and a secret:

map $remote_user $httpssh_link {
  "~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
  # […]
  location / {
    secure_link $httpssh_link;
    secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
  }
}

The module returns the status of the check in the $secure_link variable:

  • empty if the hashes do not match,
  • "0" if they match but the link has expired, or
  • "1" otherwise.

If the hash is incorrect or missing, we return a 401 error with a WWW-Authenticate header to ask for credentials. If the link has expired, we return a 410 error. We remove the Authorization header before forwarding the request and add a few directives to proxy WebSocket connections. Here is the complete configuration:5

map $remote_user $httpssh_link {
  "~^([-_A-Za-z0-9]{22})--([0-9]+)$" "$1,$2";
}
server {
  listen 0.0.0.0:443 ssl ;
  listen [::0]:443 ssl ;
  server_name ~^p(?<port>\d\d\d\d\d)\.ssh\.luffy\.cx$;
  location / {
    secure_link $httpssh_link;
    secure_link_md5 "$secure_link_expires $port ZuPerS3cr3!";
    if ($secure_link = "") {
      add_header WWW-Authenticate 'Basic realm="tunnel"' always;
      return 401;
    }
    if ($secure_link = "0") {
      return 410;
    }
    proxy_pass http://127.0.0.1:$port;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Authorization "";
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_buffering off;
    proxy_read_timeout 30m;
  }
}

I think you are now asking yourself the obvious question: β€œHow should I generate the hash?” Easy peasy!

$ expires=$(( $(date +%s) + 86400 ))
$ port=41535
$ secret='ZuPerS3cr3!'
$ printf '%s %s %s' "$expires" "$port" "$secret" \
>   | openssl md5 -binary \
>   | openssl base64 \
>   | tr +/ -_ | tr -d =
6J3jK1WmB15c6WmjW_X-Wg

Well, I suppose you are now saying: β€œVincent, this is not very convenient! I’ll stick with ngrok if you don’t mind.” Okay, I hear you. Let’s write a helper script.

Helper script

The main difficulty is finding the ephemeral port that OpenSSH allocates, as it does not appear in any environment variable.6 To work around this obstacle, we look for the ancestor sshd-session processes:7

pids=$(
  pid=$$
  while [ "$pid" -gt 1 ]; do
    line=$(ps -o comm=,pid=,ppid= -p "$pid")
    echo "$line"
    pid=${line##* }
  done | awk '$1 == "sshd-session" { printf "pid=%s,\n", $2 }'
)
if [ -z "$pids" ]; then
  echo "not an ssh session" >&2
  exit 1
fi

Then, we get the listening ports associated with these sshd-session processes:8

ports=$(sudo -n ss --listening --numeric --tcp --processes --no-header \
  | grep -F "$pids" \
  | awk '{ print $4 }' | awk -F: '{ print $NF }' \
  | sort -un)
if [ -z "$ports" ]; then
  echo "no forwarded port, use ssh -R 0:localhost:PORT" >&2
  exit 1
fi

Finally, we display the URLs and keep the session open:

lifetime=86400
secret='ZuPerS3cr3!'
expires=$(( $(date +%s) + lifetime ))
for port in $ports; do
  token=$(printf '%s %s %s' "$expires" "$port" "$secret" \
            | openssl md5 -binary \
            | openssl base64 \
            | tr +/ -_ | tr -d =)
  echo "https://$token--$expires@p$port.ssh.luffy.cx/"
done
sleep infinity

I install this script as http-over-ssh on the server and add this entry to my ~/.ssh/config:

Host http-over-ssh
  Hostname web02.luffy.cx
  RemoteCommand http-over-ssh
  ControlPath none

With this solution, I only rely on OpenSSH and nginx, two pieces of software already running on this server. One short command gives me a self-hosted tunnel and a URL to share. To try it, grab the complete helper script, which includes a few minor improvements. If you run NixOS, as any person of taste would, have a look at my http-over-ssh.nix instead. ❄️

Update (2026-10)

This post was widely discussed on Hacker News.

Update (2026-10)

I have since switched to Caddy, which does not include a module to check the validity of a URL. Instead, Caddy queries a local HTTP service written in Go through the forward_auth directive. As a bonus, this lets me put the token in the domain name.


  1. Since the kernel allocates it from the local port range, its entropy is low. Moreover, on Linux, we lose one more bit because the kernel favors odd ports when it picks a random free port.

    $ sysctl -n net.ipv4.ip_local_port_range \
    >   | awk '{print $1"β€”"$2" β‰ˆ "log($2-$1+1)/log(2)" bits"}'
    32768β€”60999 β‰ˆ 14.785 bits
    

    ↩

  2. The module relies on MD5. Its security is weak but good enough for this use. ↩

  3. We need an expiration date because a later session could reuse the port, and we have no way to detect this case. ↩

  4. The username could use a comma directly instead of a double dash. But some applications do not recognize the URL correctly, making it harder to share. ↩

  5. This configuration exposes any TCP port listening on 127.0.0.1 or 0.0.0.0 to anyone with the secret, bypassing most firewall rules. You could strengthen it by limiting the server_name regex to the ephemeral port range. ↩

  6. One SSH session can contain several tunnels, and the client can add or remove them at any time. This is unlike tun device forwarding (ssh -w), which gets its own SSH_TUNNEL environment variable. ↩

  7. Since OpenSSHβ€―9.8, sshd-session is the name of the helper handling the session. With older versions, look for sshd instead. ↩

  8. We need sudo because the sshd-session process dropped its privileges. The kernel then marks it as not dumpable, and its /proc/PID/fd directory belongs to root. Without access to this directory, ss cannot find which process owns a socket. ↩

  •  

Apple Says iPhone 18 Pro Max Users With AT&T Cellular Issue Need to Have Device Replaced

Apple today confirmed that a "small number" of iPhone 18 Pro Max users on the AT&T network have experienced an issue that causes the device to lose cellular service, thereby preventing phone calls, text messages, and data from working. Thousands of users complained about the issue across the Apple Support Community and other platforms.


Apple said it released iOS 27.0.1 earlier this week and an AT&T carrier settings update earlier today to help prevent more iPhone 18 Pro Max users from being impacted by this issue going forward. In a statement shared with MacRumors, Apple strongly encouraged all iPhone 18 Pro Max users to update to iOS 27.0.1 now. To install the update, open the Settings app on your iPhone and tap on General β†’ Software Update.

The carrier settings update for the iPhone 18 Pro Max will automatically download for AT&T users over the next 14 days, according to Apple. To manually trigger the update, users can open the Settings app and tap on General β†’ About.

Apple's full statement:
We have identified an issue affecting a small number of iPhone 18 Pro Max users on the AT&T network that may cause a device to lose service and be unable to make calls. We released iOS 27.0.1 earlier this week and strongly encourage all iPhone 18 Pro Max users to update now, and are also issuing a carrier settings update today. These updates together are meant to help prevent this issue from occurring.
Notably, Apple said that iPhone 18 Pro Max devices that have been affected by this cellular issue on the AT&T network cannot have service reactivated with a software update. Instead, Apple said the device must be replaced entirely, and it advises affected customers to contact Apple Support or AT&T, or visit an Apple Store or AT&T location.

Apple said it has only observed this issue with iPhone 18 Pro Max users on the AT&T network. No other iPhone models or carriers are known to be impacted.

Apple did not disclose the underlying cause of the issue. U.S. models of the iPhone 18 Pro Max rely on Qualcomm's Snapdragon X80 modem for cellular connectivity, while variants of the device sold in all other countries are equipped with Apple's custom C2 modem. It is unclear if this modem difference is a factor in this AT&T issue.

It is also unclear if there will eventually be an Apple Service Program dedicated to this issue. In the meantime, it sounds like Apple Stores will replace affected iPhone 18 Pro Max devices for free, so book an appointment with the Genius Bar if necessary.
Related Roundup: iPhone 18 Pro
Related Forum: iPhone

This article, "Apple Says iPhone 18 Pro Max Users With AT&T Cellular Issue Need to Have Device Replaced" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Distribution Release: Qubes OS 4.3.2

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. The Qubes team has announced the release of Qubes OS 4.3.2, an updated build of the project's security-oriented operating system for single-user desktop computing: "We're pleased to announce the stable release of Qubes OS 4.3.2. This patch release aims to consolidate all the security updates and bug fixes....
  •  

Apple Announces 'Full Disk Access' Changes on macOS Due to AI Agents

In a post on its developer news website today, Apple announced that it will be introducing "additional controls" for the "Full Disk Access" setting on macOS due to new risks posed by AI agents. This announcement comes amid the rise of always-on AI agents like Meta's Muse and OpenAI's Dots, which have prompted privacy concerns.


"Some developers are using Full Disk Access in ways that could put users at risk, exposing everything on their systemsβ€”including files, mail, messages, and even browsing historyβ€”without users' full knowledge and understanding," said Apple.

"As AI agents become increasingly capable and autonomous, the risks associated with this level of access will grow substantially," it added.

Here is the full text of Apple's announcement:
We give developers powerful APIs to build incredible capabilities into their apps for Apple products, backed by a set of controls designed to protect users' private data. Full Disk Access largely sidesteps these controls in order to allow backup apps to function properly on the Mac. Some developers are using Full Disk Access in ways that could put users at risk, exposing everything on their systemsβ€”including files, mail, messages, and even browsing historyβ€”without users' full knowledge and understanding. For communication apps, this can also compromise the privacy of the people users are communicating with.

Going forward, we will introduce additional controls to ensure that users who genuinely wish to grant an app this extraordinary level of access can only do so with very explicit user action. Addressing this is critical. As AI agents become increasingly capable and autonomous, the risks associated with this level of access will grow substantially. We are committed to ensuring users clearly understand these risks before granting such access, so they can make informed decisions about their own data and privacy.
Apple did not say when the new "Full Disk Access" controls will be implemented.
Related Roundup: macOS Golden Gate
Related Forum: macOS Golden Gate

This article, "Apple Announces 'Full Disk Access' Changes on macOS Due to AI Agents" first appeared on MacRumors.com

Discuss this article in our forums

  •  

AT&T and Apple Just Took Steps Toward Fixing iPhone 18 Pro Max Issue

AT&T and Apple are taking action today as they try to resolve iPhone 18 Pro Max cellular issues, according to MacRumors contributor Aaron Perris.


Thousands of iPhone 18 Pro Max users have been unable to connect to the AT&T network for phone calls, text messages, and data. The underlying cause has yet to be disclosed, but AT&T and Apple continue to work toward a resolution.

First, Apple today released an AT&T carrier settings update (version 72.1, up from 72.0.2) for iPhones. Given the timing of this update, it presumably includes some optimizations related to iPhone 18 Pro Max cellular connectivity, but it is unclear if AT&T has fully resolved the issues or if further tweaks will be necessary.

When a carrier settings update is available on your iPhone, you will be prompted to install it. If you dismiss the notification or do not see it, open the Settings app, tap General β†’ About, and the update should appear there. You can see your iPhone's current carrier settings version in the same About menu, next to your carrier's name.

Second, Apple today started requiring iPhone 18 Pro Max users to install iOS 27.0.1 in order to set up the device, even though most if not all affected users have said that the update does not resolve the issues. A leaked AT&T email said iOS 27.0.1 is meant to help mitigate future impacts, so it may still help in some way.

In a statement shared with MacRumors today, AT&T said it is "working with Apple" on this.

Apple did not respond to our request for comment.

It is not yet clear if a follow-up update like iOS 27.0.2 will be required as part of a fix, or if the carrier update alone will be sufficient.
Related Roundups: iOS 27, iPadOS 27, iPhone 18 Pro
Related Forums: iOS 27, iPhone

This article, "AT&T and Apple Just Took Steps Toward Fixing iPhone 18 Pro Max Issue" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Distribution Release: Mainstream OS 3.0.0

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. Greg Martin has announced the release of Mainstream OS 3.0.0, a milestone version of the project's Arch-derived desktop and gaming Linux distribution with a highly customised Hyprland-based desktop. The new release comes with a long list of improvements: "A rebuilt Welcome app, smoother gaming with a new Gaming....
  •  

Announcing new builds for 2 October 2026

Hello Windows Insiders, Today, we’re releasing new Windows 11 Insider Preview builds to the Beta and Experimental channels. New builds this week Select your channel below to view its release notes: For those on other specific Windows build versions, here are today’s new builds and release notes: Also a reminder that next week October 7, we have our Windows and Surface event at 10AM Pacific time! Tune in to watch live via the Windows YouTube channel.Β  Thanks, Stephen and the Windows Insider Program team
  •  

AT&T on iPhone 18 Pro Max Issues: 'We Are Working With Apple'

In a statement shared with MacRumors today, AT&T said it is "working with Apple" to resolve iPhone 18 Pro Max cellular issues.


"Apple has released a software update to address an issue that may affect calls, data, texts, and 911 calls on a small number of Apple iPhone 18 Pro Max phones," an AT&T spokesperson said. "AT&T customers with an iPhone 18 Pro Max should update their phones now to the latest available software. Our top priority is ensuring customers can stay connected when they need it most, and we are working with Apple to make it right."

As we reported last week, affected iPhone 18 Pro Max users are unable to connect to the AT&T network for phone calls, text messages, or data either persistently or intermittently, and their devices typically enter SOS mode as a result. The underlying cause has not been confirmed, and Apple has yet to publicly comment on the matter.

Thousands of users have experienced this problem since the iPhone 18 Pro Max launched on September 18, according to discussion threads across the Apple Support Community, Reddit, MacRumors Forums, and other online platforms.

Notably, the U.S. version of the iPhone 18 Pro Max is the only new iPhone unveiled last month that is equipped with a Qualcomm cellular modem. The smaller iPhone 18 Pro and the iPhone Duo are equipped with Apple's C2 modem worldwide, as is the iPhone 18 Pro Max in all other countries. However, it is unclear if this is a contributing factor.

AT&T said iPhone 18 Pro Max users who have experienced or continue to experience issues can return to an Apple or AT&T store for additional support.

Yesterday, AT&T sent a text message to some customers advising them to update to iOS 27.0.1 immediately, even though that update does not appear to resolve the issues, according to comments shared by affected users.

In an alleged internal email shared on Reddit today, AT&T clarified that its guidance about updating to iOS 27.0.1 is meant to help mitigate future impacts, but it is unclear if or how the update actually helps. Apple's release notes for iOS 27.0.1 only mentioned fixes for other bugs, including a Face ID issue affecting iPhone 18 Pro models.

The alleged email added that customers can visit an Apple or AT&T store for a replacement device, but some customers said their request for a replacement under warranty was declined. With this issue becoming more widespread, hopefully Apple and AT&T are working to inform employees about the issue and implement a formal process.

iPhone 18 Pro Max users should be aware that today is the final day to return the device to Apple under the company's standard 14-day return policy in the U.S., for customers who purchased or received it on September 18 launch day.

Typically, Apple is able to address issues like these with software updates, so hopefully it will be able to fix the issue in iOS 27.0.2 or later. Hardware issues are more rare, but when they do happen, Apple sometimes launches a free repair program. We will continue to provide further updates on this topic as more information becomes available.
Related Roundup: iPhone 18 Pro
Related Forum: iPhone

This article, "AT&T on iPhone 18 Pro Max Issues: 'We Are Working With Apple'" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Beats 360 Cushion Kits and Apple Accessories See Early Prime Day Price Drops

We're just a few days out from Prime Big Deal Days on October 6, and Amazon is already offering some savings on accessories including Anker chargers, Samsung monitors, and 4K TVs. We're also tracking deals on the new Mac mini, Mac Studio, and Apple Watch Ultra 4 this week.

Note: MacRumors is an affiliate partner with some of these vendors. When you click a link and make a purchase, we may receive a small payment, which helps us keep the site running.

Early Prime Day Deals



  • What's the deal? Shop early Prime Day deals on Apple-related accessories and more

  • Where can I get it? Amazon

  • Where can I find the original deal? Right here



Amazon's fall Prime Day, called Prime Big Deal Days, is nearly upon us, and early discounts have already started appearing online. We're tracking big markdowns on TVs, monitors, charging accessories, and more in our original article, and be sure to keep an eye on the MacRumors front page for more Prime Big Deal Days news as the event gets closer.

Beats 360



  • What's the deal? Save on Beats 360 cushion kits and more

  • Where can I get it? Amazon

  • Where can I find the original deal? Right here




The new Beats 360 headphones were announced last week, and they're now available to order on Amazon and other retailers. They're available in Black, Cloud, plus a bundle where you can style the headphones however you'd like thanks to interchangeable ear cushions and headbands.

Alongside the launch, Beats announced that its Cushion Kits compatible with the Beats 360 are available for just $9.99 for the next month, down from $49.99. Stock on these has begun to dwindle since launch, so be sure to get in soon before they disappear.

Mac Mini and Mac Studio



  • What's the deal? Take up to $50 off Mac mini and Mac Studio

  • Where can I get it? Amazon

  • Where can I find the original deal? Right here






Amazon recently introduced the first cash discounts on Apple's brand new Mac mini and Mac Studio, with discounts reaching up to $50 off original prices. All of these deals represent the first discounts on the new computers, which just launched last month.

Apple Watch Ultra 4



  • What's the deal? Take $19 off Apple Watch Ultra 4

  • Where can I get it? Amazon

  • Where can I find the original deal? Right here



The new Apple Watch Ultra 4 just launched last month, and Amazon is offering the first cash discounts on select models for the first time since their launch. You can get the Apple Watch Ultra 4 for $779.99, down from $799.00.

If you're on the hunt for more discounts, be sure to visit our Apple Deals roundup where we recap the best Apple-related bargains of the past week.




Deals Newsletter


Interested in hearing more about the best deals you can find in 2026? Sign up for our Deals Newsletter and we'll keep you updated so you don't miss the biggest deals of the season!




Related Roundup: Apple Deals

This article, "Beats 360 Cushion Kits and Apple Accessories See Early Prime Day Price Drops" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Upgrading From an Older Apple Watch? Here's How the New Features Stack Up

The Apple Watch has received some incremental updates in recent years, with significant upgrades becoming fewer and farther between as the device has progressed. Since the Apple Watch's generation-over-generation enhancements are relatively small, it is all the more important to consider when an upgrade is justified and whether an older model could suffice for your needs.


The Apple Watch Series 12 brings a more substantial set of changes than the Series 11, which was one of the smallest refreshes in the device's history. While Apple has discontinued previous Series models, older watches may still be available at lower prices from third-party retailers or secondhand.

Read on to see the breakdown below for each new feature, change, and improvement that was added with each Apple Watch model compared to its direct predecessor. Viewing each generation's changes in this way helps weigh up the accumulative upgrades one receives by skipping multiple generations when buying a new device, as well as get a sense of which generations offered the biggest overall upgrades.

Apple Watch Series 12 (2026)



  • S11 chip (based on A20 Pro)

  • 4GB of RAM

  • New Health Sensing System

  • Second-generation electrical heart sensor

  • Fourth-generation, always-on optical heart sensor

  • Background heart rate measurements every five seconds throughout the day

  • Heart rate variability measured up to 24x more often

  • Recovery HRV metric

  • Readiness app

  • Daytime view in the Vitals app

  • More accurate step tracking

  • Steps and real-time heart rate watch face complications

  • Audio Intelligence with Sound Recognition and automatic Shazam detection

  • Live Rewind and Siri Recap

  • Ceramic Shield 2 front glass, 60% tougher than Series 11's Ion-X glass (aluminum models only)

  • Aluminum or titanium and zirconia back replaces metal and sapphire crystal back

  • Up to 10 hours of outdoor workout tracking (25% longer than Series 11)

  • 15 minutes of charging provides up to 12 hours of normal use (up from eight hours)

  • Five minutes of charging provides up to 10 hours of sleep tracking (up from eight hours)

  • Aluminum and titanium cases are 1mm wider and around two grams heavier

  • Slightly larger bezels and display area is approximately 2% smaller despite unchanged screen resolutions

  • Ceramic case option introduced in Pearl White and Night Blue, with slightly larger dimensions than aluminum and titanium models

  • Dark Bronze, Light Gold, and Black aluminum finishes replace Silver, Rose Gold, and Jet Black; Space Gray remains available

  • Radiant Gold titanium replaces Gold and Slate; Natural titanium remains available



Apple Watch Series 11 (2025)



  • 2x more scratch-resistant Ion-X glass (aluminum models only)

  • 5G connectivity (cellular models only)

  • 7–10% larger battery

  • Space Gray aluminum option reintroduced



Apple Watch Series 10 (2024)



  • Refined design with a thinner casing, display with more rounded edges and a wider aspect ratio, and front glass that extends further over the sides for a higher screen-to-body ratio
  • 42mm and 46mm case sizes

  • Larger displays

  • LTPO3 display for faster refresh rate in always-on mode, enabling a live ticking seconds hand on select watch faces

  • Wide-angle OLED (up to 40% brighter when viewed at an angle)

  • S10 chip (same CPU as S9, but more compact)

  • Water temperature sensor

  • Depth app and Depth gauge to 6m

  • Microphone with voice isolation

  • Redesigned speaker with media playback

  • Metal back with larger charging coil and integrated antenna

  • Faster charging (0–80% in about 30 minutes)

  • Titanium casing replaces stainless steel option, now in Silver (matches previous stainless steel casing, Apple Watch HermΓ¨s only), Gold, Slate, and Natural

  • Starlight, Midnight, Pink, and (PRODUCT)RED finishes discontinued, replaced with Silver, Rose Gold, and polished Jet Black finishes



Apple Watch Series 9 (2023)



  • Retina display with up to 2,000 nits of brightness

  • Display can get dimmer in dark rooms and at night (down to 1 nit of brightness)

  • Double tap gesture

  • Wrist flick gesture

  • Sleep apnea detection

  • Hypertension detection

  • S9 chip (based on A15 Bionic)

  • 2GB of RAM

  • 4-core Neural Engine (2x faster than Series 8)

  • β€ŒSiriβ€Œβ€Œ processed on-device for requests that do not need information from the internet

  • β€Œβ€ŒSiriβ€Œβ€Œ can access health and fitness data

  • 25% more accurate dictation

  • Second-generation Ultra Wideband chip

  • Precision Finding for iPhone 15 models

  • HomePod proximity integration

  • 64GB storage

  • Pink aluminum casing introduced



Apple Watch Series 8 (2022)



  • S8 chip (same CPU as S6)

  • More powerful gyroscope and high dynamic range accelerometer

  • β€ŒCrash Detection

  • Body temperature sensor

  • Wrist temperature sensing during sleep
  • mem
  • Cycle tracking with retrospective ovulation estimates

  • Blue and Green aluminum casings discontinued, Silver aluminum reintroduced

  • Apple Watch Edition discontinued



Apple Watch Series 7 (2021)



  • 41mm and 45mm case sizes

  • 1.7mm display borders for almost 20% more screen area than Series 6

  • QWERTY keyboard

  • Up to 70% brighter display than Series 6 indoors when wrist is down

  • S7 chip (Same CPU as S6)

  • Crack-resistant front crystal (50% thicker)

  • IP6X dust resistance

  • Fast charging for up to 80% charge in about 45 minutes (33% faster than Series 6)

  • Silver and Space Gray aluminum casings discontinued, Midnight, Starlight, and Green introduced



Apple Watch Series 6 (2020)



  • Up to 2.5x brighter always-on display

  • Access Notification Center, Control Center, and Complications when display is asleep

  • S6 chip (based on A13 Bionic, 20% faster than S5)

  • 1.5GB of RAM

  • Always-on altimeter

  • First-generation Ultra Wideband chip

  • 2.4GHz and 5GHz Wi-Fi

  • Blood oxygen sensor

  • Faster charging for a full charge in under 1.5 hours

  • Improved battery life for tracking certain workouts, such as indoor and outdoor runs

  • Blue and (PRODUCT)RED aluminum casing options introduced

  • Space Black stainless steel casing available with Apple Watch HermΓ¨s only, Graphite option introduced

  • Ceramic Apple Watch Edition discontinued



Apple Watch Series 5 (2019)



  • Always-on Retina display

  • Force Touch removed

  • S5 chip

  • Compass

  • 32GB storage

  • Apple Watch Edition reintroduced with White Ceramic, Natural Titanium, and Space Black Titanium casing options



Apple Watch Series 4 (2018)



  • 40mm and 44mm case sizes

  • Over 30% larger display with curved corners

  • 3.0mm display borders

  • S4 chip (2x faster than S3)

  • Gyroscope and accelerometer

  • Barometric altimeter

  • Fall detection

  • 2.4GHz Wi-Fi

  • Optical heart sensor and electrical heart sensor for ECG

  • 16GB storage

  • Digital Crown with haptic feedback

  • 50% louder speaker

  • Relocated microphone for reduced echo and better sound quality

  • Faster charging for a full charge in 1.5 hours

  • Gold stainless steel casing introduced

  • Apple Watch Edition discontinued



Apple Watch Series 3 (2017)



  • S3 chip (70% faster than S2)

  • Barometric altimeter

  • Bluetooth 4.2

  • β€ŒSiriβ€Œ able to speak through the onboard speaker

  • LTE cellular model available with red Digital Crown

  • Rose Gold aluminum casing discontinued

  • Gray Ceramic Apple Watch Edition model introduced



Apple Watch Series 2 (2016)



  • Retina display with up to 1,000 nits of brightness (2x brighter than previous generation)

  • S2 chip (dual‑core)

  • 1GB of RAM

  • GPS and GLONASS receiver

  • Water resistant up to 50 meters

  • Gold and Rose Gold aluminum casing options added

  • 18K Gold and Rose Gold Apple Watch Edition discontinued, Ceramic model introduced in White

  • Apple Watch Nike+ variant introduced



The Series 12 has a stronger case than the Series 11 as an upgrade in general, but most Series 11 owners still have little reason to get a new device. Its new heart sensors, more frequent HRV measurements, Readiness app, and longer workout battery life matter most if you rely on your Apple Watch for training and recovery insights. Sound Recognition may also appeal to deaf or hard of hearing users, while the ceramic case offers a finish unavailable with the Series 11.

Upgrading from a Series 10 brings these changes alongside the Series 11's 5G connectivity on cellular models and longer rated everyday battery life. From a Series 9, you would also gain the thinner Series 10 design, larger display, depth gauge, and water temperature sensor.

The cumulative upgrades become more compelling for Series 8 users and older, particularly if battery wear is affecting daily use. Series 7 owners gain Crash Detection and temperature sensing as well as the newer design and Series 12 health features. The biggest differences will be felt by those with a Series 6 or older, though the condition of your current watch and the cost of replacing it still matter.

watchOS 27 supports Series 9 and later, leaving the Series 8 and older without the latest major software release. That does not mean they will immediately stop working or receiving all security updates, but they are now considered old devices. If you are shopping for a replacement, a discounted or secondhand Series 11 may offer better value if you do not need the Series 12's health features, providing you can find it at a sufficiently low price. watchOS 26 is not supported by the Series 5 or older, so anyone with one of these devices should definitely consider upgrading soon.

Looking to the future, it looks like next year's Apple Watch Series 13 could offer some major upgrades including a redesign and new sensors. This could make it a great upgrade for those with a Series 11 or older.
Related Roundup: Apple Watch 12
Buyer's Guide: Apple Watch (Buy Now)
Related Forum: Apple Watch

This article, "Upgrading From an Older Apple Watch? Here's How the New Features Stack Up" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Ben Hutchings: FOSS activity in September 2026

  •  

Apple Store in UK Reopening on iPhone Duo Launch Day

Following more than three months of renovations, the Apple SouthGate store in Bath, England will be reopening on Friday, October 23, just in time for iPhone Duo launch day.

Last year, Apple permanently closed its store in the heart of nearby Bristol, but Apple Cribbs Causeway remains open inside a shopping center on the outskirts of the city. In other UK store news, Apple Lakeside in Essex was also renovated and reopened in September.
Related Roundup: iPhone Duo
Related Forum: iPhone

This article, "Apple Store in UK Reopening on iPhone Duo Launch Day" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Distribution Release: Koozali SME Server 11.0

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. Terry Fage has announced the release of Koozali SME Server 11.0. The latest release of the server distribution shifts its base from CentOS to Rocky Linux and introduces an overhaul of the web interface. "The Koozali SME Server development team is pleased to announce the release of SME....
  •  

AT&T Acknowledges iPhone 18 Pro Max Issues: Update 'Immediately'

In a text message sent to customers today, AT&T acknowledged that some iPhone 18 Pro Max devices on its network are experiencing cellular issues.


AT&T said that customers should immediately update to iOS 27.0.1, despite the fact that many affected users have said that the update does not resolve the issues. There are now at least thousands of users that have experienced this problem, according to discussion topics across the Apple Support Community, Reddit, and MacRumors Forums.

Here is what the text message says:
Hi, it's AT&T. Action needed: An important Apple software update for your iPhone 18 Pro Max is available. Some phones may be unable to complete calls/text/data, including to 911. Update to iOS 27.0.1 immediately β€” Settings > General > Software Update. Please contact AT&T or Apple if you have questions.
Apple's release notes for iOS 27.0.1 did not mention a fix for AT&T cellular issues. Rather, the update fixed a Face ID issue on the iPhone 18 Pro models.

As we reported last week, affected iPhone 18 Pro Max users are unable to connect to the AT&T network for phone calls, text messages, or data either persistently or intermittently, and their devices typically enter SOS mode as a result. The underlying cause has not been confirmed, and Apple has yet to publicly comment on the matter.

Notably, the U.S. version of the iPhone 18 Pro Max is the only new iPhone unveiled last month that is equipped with a Qualcomm cellular modem. The smaller iPhone 18 Pro and the iPhone Duo are equipped with Apple's C2 modem worldwide, as is the iPhone 18 Pro Max in all other countries. However, it is unclear if this is a contributing factor.

If a software issue is identified, then another update such as iOS 27.0.2 may be required. If there is a hardware issue, though, then this would obviously be a much bigger problem. We will provide further updates as new information becomes available.
Related Roundup: iPhone 18 Pro
Related Forum: iPhone

This article, "AT&T Acknowledges iPhone 18 Pro Max Issues: Update 'Immediately'" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Distribution Release: umbrelOS 2.0.0

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. Umbrel, Inc. has announced the release of umbrelOS 2.0.0, an updated version of the company's Debian-based Linux distribution designed for home servers, with a web-based user interface and an online app store. It is available for standard 64-bit and Raspberry Pi computers. "umbrelOS 2.0 is the biggest update....
  •  

Development Release: Ubuntu 26.10 Beta

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. Utkarsh Gupta has announced a beta snapshot of Ubuntu 26.10, which carries the code name "Stonking Stingray". "The Ubuntu Release team is pleased to announce the Beta release of the Ubuntu 26.10 Desktop, Server, WSL, and Cloud products. Ubuntu 26.10, codenamed "Stonking Stingray", continues Ubuntu's proud tradition of....
  •  

New 'AirFly Drive' Accessory Turns Wired CarPlay Into Wireless CarPlay

Twelve South, a well-known accessory maker for Apple products, has released a new AirFly Drive accessory that turns wired CarPlay into wireless CarPlay.


AirFly Drive uses Wi-Fi to establish a connection between your iPhone and vehicle, eliminating the need to use a USB-C cable for CarPlay.

Priced at $39.99 in the U.S., the AirFly Drive plugs into a USB data port in your vehicle. The accessory is essentially a compact aluminum cube with a USB-A port on it, with a USB-C adapter included in the box. It is designed to stay plugged in, so that you can get in your vehicle and use wireless CarPlay right away every time.

You only need to pair your iPhone with the AirFly Drive once, and then it will offer fast automatic reconnection every time the vehicle starts, with no app, account, or charging required. A button on the accessory allows for one-press switching between two paired iPhones, which is useful if two people share the same vehicle.


Before first-time setup, you must disconnect your iPhone from your vehicle by choosing "Forget This Device" in the vehicle's Bluetooth settings.

Twelve South says the AirFly Drive works with any iPhone running iOS 10 or newer and any vehicle that already has factory support for wired CarPlay.

Similar wired-to-wireless CarPlay dongles from other brands are available on Amazon, but Twelve South is a reputable maker of accessories for Apple products. AirFly Drive is available to order now in white or black.
Related Roundup: CarPlay

This article, "New 'AirFly Drive' Accessory Turns Wired CarPlay Into Wireless CarPlay" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Apple Shares Photos Shot on iPhone Duo

Apple on Instagram today shared a curated gallery of six photos shot on the iPhone Duo. The photos were shot by American photographers Jake Michaels and Jason Nocito, as part of a project commissioned by Apple.


iPhone Duo is equipped with four cameras:
  • A rear 48-megapixel Fusion Main camera

  • A rear 48-megapixel Ultra Wide camera

  • A front 12-megapixel Center Stage camera in the outer display

  • A front camera under the inner display
iPhone Duo lacks a dedicated Telephoto camera, which is a dealbreaker for some customers. The main camera does have "optical-quality 2Γ— Telephoto" capabilities, but this pales in comparison to the iPhone 17 Pro and iPhone 18 Pro models, which can achieve up to 8Γ— optical zoom. Still, the photos shared by Apple prove that the iPhone Duo is still very capable for photography, at least in the hands of skilled photographers.

You can pre-order the iPhone Duo starting Friday, October 16 at 5 a.m. Pacific Time.

Related Roundup: iPhone Duo
Tag: Photos
Related Forum: iPhone

This article, "Apple Shares Photos Shot on iPhone Duo" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Apple Says iPhone Duo Has Replaceable 'Cover Layer'

In a recent interview with content creator Angelica Siciliani Fendi, Apple's vice president of hardware engineering Kate Bergeron revealed that the iPhone Duo's nano-texture cover layer above the inner display can be removed and replaced.


"It's actually replaceable, which is a really nice thing for the customer that you can peel off that layer and get a new one," said Bergeron.

In the U.S., customers who purchase AppleCare+ coverage for the iPhone Duo can get the cover layer replaced for $19, according to Apple's website. The cost to replace the cover layer without coverage has yet to be disclosed.

The cover layer helps to hide the crease in the middle of the foldable inner display. Bergeron said Apple did a "significant amount of engineering work" to ensure that this layer remains in good condition for as long as possible, but customers will have the option to replace it if it wears down or the crease becomes more visible over time.

Officially, the ability to replace the iPhone Duo's cover layer will likely be limited to Apple Stores and Apple Authorized Service Providers. It remains to be seen if customers will be able to easily remove the layer themselves.

iPhone Duo pre-orders begin on Friday, October 16 at 5 a.m. Pacific Time, and the device will launch in stores on Friday, October 23.
Related Roundup: iPhone Duo
Related Forum: iPhone

This article, "Apple Says iPhone Duo Has Replaceable 'Cover Layer'" first appeared on MacRumors.com

Discuss this article in our forums

  •  

Distribution Release: Nitrux 7.0.0

The DistroWatch news feed is brought to you by TUXEDO COMPUTERS. Uri Herrera has announced the release of Nitrux 7.0.0, a significant update of the project's Debian-based Linux distribution featuring an immutable base and the Hyprland Wayland compositor: "We are pleased to announce the launch of Nitrux 7.0.0. This new version combines the latest software updates, bug fixes, performance....
  •  

Apple's Smart Home Camera Will Apparently Have 'No Video Recording'

On his new Power On podcast, Bloomberg's Mark Gurman said Apple's smart home camera will have "no video recording."

Instead, he described the rumored product as a sensor that will be capable of "analyzing your environment in your home" using AI. "This is going to be the first mainstream video camera that's a home video security camera that doesn't give you any footage," he said.
This article, "Apple's Smart Home Camera Will Apparently Have 'No Video Recording'" first appeared on MacRumors.com

Discuss this article in our forums

  •  
❌