How Go's crypto Package Uses AES-NI and SHA-NI

Go detects AES-NI, SHA-NI and AVX2 at startup with CPUID, then dispatches crypto/aes and crypto/sha256 to hand-written assembly that chains AESENC and SHA256RNDS2 instructions, with a table-driven pure-Go fallback for CPUs without them.

TL;DR: crypto/aes and crypto/sha256 are thin shims over crypto/internal/fips140, where a CPUID check at start-up decides whether your machine has AES-NI, SHA-NI and AVX2. If it does, an AES block is ten AESENC instructions and a SHA-256 block is sixteen SHA256RNDS2 instructions. If it does not, a table-driven fallback runs — and that fallback is not constant-time.

Go’s crypto package is fast for a boring reason: on any CPU made in the last fifteen years, the interesting arithmetic happens in a handful of instructions that do exactly what the algorithm says. There is no software AES, no bitsliced cleverness, no hand-rolled GF(2^128) arithmetic on the hot path. The CPU does it.

The interesting part is how the choice is made, and the fact that the layout of the tree changed dramatically a few releases ago.

The public packages are now shims

crypto/aes used to own the assembly. It no longer does:

go
// src/crypto/aes/aes.go:32
// NewCipher creates and returns a new [cipher.Block].
// The key argument must be the AES key,
// either 16, 24, or 32 bytes to select
// AES-128, AES-192, or AES-256.
func NewCipher(key []byte) (cipher.Block, error) {
	k := len(key)
	switch k {
	default:
		return nil, KeySizeError(k)
	case 16, 24, 32:
		break
	}
	if boring.Enabled {
		return boring.NewAESCipher(key)
	}
	return aes.New(key)
}

aes.New is crypto/internal/fips140/aes.New. The same is true of crypto/sha256, which forwards to crypto/internal/fips140/sha256. The reason is FIPS 140-3 validation: the primitives had to be restructured into a single internal package that could be validated as a unit, with the public packages left as thin, stable API surfaces.

That restructuring also moved the assembly. There is no src/crypto/aes/cipher_amd64.s any more. The AES-NI code is at src/crypto/internal/fips140/aes/aes_amd64.s, and the SHA-NI code is at src/crypto/internal/fips140/sha256/sha256block_amd64.s.

Detecting the hardware

The dispatch decision is made from CPUID, once, at start-up. The bits are defined in src/internal/cpu/cpu_x86.go:

go
// src/internal/cpu/cpu_x86.go:125
	_, _, ecx1, _ := cpuid(1, 0)

	X86.HasSSE3 = isSet(ecx1, cpuid_SSE3)
	X86.HasPCLMULQDQ = isSet(ecx1, cpuid_PCLMULQDQ)
	X86.HasSSSE3 = isSet(ecx1, cpuid_SSSE3)
	X86.HasSSE41 = isSet(ecx1, cpuid_SSE41)
	X86.HasSSE42 = isSet(ecx1, cpuid_SSE42)
	X86.HasPOPCNT = isSet(ecx1, cpuid_POPCNT)
	X86.HasAES = isSet(ecx1, cpuid_AES)

cpuid_AES is bit 25 of ECX from leaf 1 — the AES-NI feature bit. The SHA extensions live in the extended leaf 7 instead, which is why SHA-NI support is detected separately:

go
// src/internal/cpu/cpu_x86.go:172
	X86.HasAVX2 = isSet(ebx7, cpuid_AVX2) && osSupportsAVX
	X86.HasBMI2 = isSet(ebx7, cpuid_BMI2)
	X86.HasERMS = isSet(ebx7, cpuid_ERMS)
	X86.HasADX = isSet(ebx7, cpuid_ADX)
	X86.HasSHA = isSet(ebx7, cpuid_SHA)

The crypto packages do not import internal/cpu directly; they go through a dependency shim so that the FIPS package’s dependency graph stays clean:

go
// src/crypto/internal/fips140deps/cpu/cpu.go:47
	X86HasAES       = cpu.X86.HasAES
	X86HasADX       = cpu.X86.HasADX
	X86HasAVX       = cpu.X86.HasAVX
	X86HasAVX2      = cpu.X86.HasAVX2
	X86HasBMI2      = cpu.X86.HasBMI2
	X86HasPCLMULQDQ = cpu.X86.HasPCLMULQDQ
	X86HasSHA       = cpu.X86.HasSHA
	X86HasSSE41     = cpu.X86.HasSSE41
	X86HasSSSE3     = cpu.X86.HasSSSE3

And the AES package turns those bits into a single boolean, decided at compile time by build tag and at run time by the CPU:

go
// src/crypto/internal/fips140/aes/aes_asm.go:5
//go:build (amd64 || arm64 || loong64 || ppc64 || ppc64le) && !purego

package aes

import (
	"crypto/internal/fips140deps/cpu"
	"crypto/internal/fips140deps/godebug"
	"crypto/internal/impl"
)

//go:noescape
func encryptBlockAsm(nr int, xk *uint32, dst, src *byte)

//go:noescape
func decryptBlockAsm(nr int, xk *uint32, dst, src *byte)

//go:noescape
func expandKeyAsm(nr int, key *byte, enc *uint32, dec *uint32)

var supportsAES = cpu.X86HasAES && cpu.X86HasSSE41 && cpu.X86HasSSSE3 ||
	cpu.ARM64HasAES || cpu.LOONG64HasLSX || cpu.PPC64 || cpu.PPC64le

Note the compound requirement: AES-NI alone is not enough, you also want SSSE3 and SSE4.1 for the shuffle instructions used around it. Note also the !purego tag — the purego build tag forces the generic implementation everywhere, and the FIPS module refuses to run that way at all.

impl.Register("aes", "AES-NI", &supportsAES) records the choice in a registry, which is how the crypto test suite can force the generic path and check that it agrees with the hardware path.

The AES-NI encrypt chain

Here is the whole of the hardware AES encryption for one block. This is not a paraphrase; it is the generated assembly:

asm
// src/crypto/internal/fips140/aes/aes_amd64.s:8
// Requires: AES, SSE, SSE2
TEXT ·encryptBlockAsm(SB), NOSPLIT, $0-32
	MOVQ   nr+0(FP), CX
	MOVQ   xk+8(FP), AX
	MOVQ   dst+16(FP), DX
	MOVQ   src+24(FP), BX
	MOVUPS (AX), X1
	MOVUPS (BX), X0
	ADDQ   $0x10, AX
	PXOR   X1, X0
	SUBQ   $0x0c, CX
	JE     Lenc192
	JB     Lenc128
	MOVUPS (AX), X1
	AESENC X1, X0
	MOVUPS 16(AX), X1
	AESENC X1, X0
	ADDQ   $0x20, AX

Lenc192:
	MOVUPS (AX), X1
	AESENC X1, X0
	MOVUPS 16(AX), X1
	AESENC X1, X0
	ADDQ   $0x20, AX

Lenc128:
	MOVUPS     (AX), X1
	AESENC     X1, X0
	MOVUPS     16(AX), X1
	AESENC     X1, X0
	MOVUPS     32(AX), X1
	AESENC     X1, X0
	MOVUPS     48(AX), X1
	AESENC     X1, X0
	MOVUPS     64(AX), X1
	AESENC     X1, X0
	MOVUPS     80(AX), X1
	AESENC     X1, X0
	MOVUPS     96(AX), X1
	AESENC     X1, X0
	MOVUPS     112(AX), X1
	AESENC     X1, X0
	MOVUPS     128(AX), X1
	AESENC     X1, X0
	MOVUPS     144(AX), X1

Read it as a whole and the algorithm is visible:

  1. PXOR X1, X0 — the AddRoundKey of round zero, XORing the plaintext with the first round key.
  2. AESENC nine times for AES-128 (ten for AES-192, thirteen for AES-256) — each one is SubBytes, ShiftRows, MixColumns and AddRoundKey in a single instruction, with one cycle of latency each and one instruction of throughput. AES-NI is pipelined: the rounds are independent given the round keys, so the CPU can start round N+1 before round N has finished.
  3. AESENCLAST once — the final round, with no MixColumns, because MixColumns is pointless on the last round.

The branching is on nr, the round count: SUBQ $0x0c, CX then JE Lenc192 / JB Lenc128. AES-128 has 10 rounds, AES-192 has 12, AES-256 has 14. The code falls through the AES-256 case (two extra AESENCs) into the AES-192 case (two more) into the AES-128 case (nine more plus the last). One code path, three key sizes, no loops — the unrolled straight-line code is what lets the CPU’s instruction window stay full.

Decryption is the mirror image, with AESDEC and AESDECLAST.

Key expansion

The round keys are computed once, when the cipher is created. The hardware has a dedicated instruction for the trickiest part of that — AESKEYGENASSIST:

go
// src/crypto/internal/fips140/aes/aes_amd64.s:109
// func expandKeyAsm(nr int, key *byte, enc *uint32, dec *uint32)
// Requires: AES, SSE, SSE2
TEXT ·expandKeyAsm(SB), NOSPLIT, $0-32
	MOVQ   nr+0(FP), CX
	MOVQ   key+8(FP), AX
	MOVQ   enc+16(FP), BX
	MOVQ   dec+24(FP), DX
	MOVUPS (AX), X0

	// enc
	MOVUPS          X0, (BX)
	ADDQ            $0x10, BX
	PXOR            X4, X4
	CMPL            CX, $0x0c
	JE              Lexp_enc192
	JB              Lexp_enc128
	MOVUPS          16(AX), X2
	MOVUPS          X2, (BX)
	ADDQ            $0x10, BX
	AESKEYGENASSIST $0x01, X2, X1
	CALL            _expand_key_256a<>(SB)
	AESKEYGENASSIST $0x01, X0, X1
	CALL            _expand_key_256b<>(SB)
	AESKEYGENASSIST $0x02, X2, X1
	CALL            _expand_key_256a<>(SB)
	AESKEYGENASSIST $0x02, X0, X1
	CALL            _expand_key_256b<>(SB)
	AESKEYGENASSIST $0x04, X2, X1
	CALL            _expand_key_256a<>(SB)
	AESKEYGENASSIST $0x04, X0, X1
	CALL            _expand_key_256b<>(SB)

AESKEYGENASSIST takes the round constant as an immediate — $0x01, $0x02, $0x04, up to $0x40 — and produces the SubWord-and-RotWord intermediate value. The rest of the expansion (the SubWord, the XOR chain, the special 256b path that mixes in the S-box output for AES-256) is hand-written SSE code.

The decryption key schedule is derived from the encryption one with AESIMC, which applies the inverse MixColumns to each round key — that is why decryption is a straight AESDEC chain with the same shape as encryption.

The assembly is generated

One more thing worth knowing: this .s file is not hand-written. It is generated by Avo, from a Go program that lives in the tree:

go
// src/crypto/internal/fips140/aes/_asm/standard/aes_amd64.go:42
func encryptBlockAsm() {
	Implement("encryptBlockAsm")
	Attributes(NOSPLIT)
	AllocLocal(0)

	Load(Param("nr"), RCX)
	Load(Param("xk"), RAX)
	Load(Param("dst"), RDX)
	Load(Param("src"), RBX)
	MOVUPS(Mem{Base: AX}.Offset(0), X1)
	MOVUPS(Mem{Base: BX}.Offset(0), X0)
	ADDQ(Imm(16), RAX)
	PXOR(X1, X0)
	SUBQ(Imm(12), RCX)
	JE(LabelRef("Lenc192"))
	JB(LabelRef("Lenc128"))

	Label("Lenc256")
	MOVUPS(Mem{Base: AX}.Offset(0), X1)
	AESENC(X1, X0)
	MOVUPS(Mem{Base: AX}.Offset(16), X1)
	AESENC(X1, X0)
	ADDQ(Imm(32), RAX)

The generated file even says so in its first line:

go
// src/crypto/internal/fips140/aes/aes_amd64.s:1
// Code generated by command: go run asm_amd64.go -out ../../asm_amd64.s -pkg aes. DO NOT EDIT.

Writing assembly in Go means the AES-NI sequence is type-checked, and the same trick is used for the SHA-256 AVX2 implementation, which is generated by sha256block_amd64_asm.go.

The modes: CBC, CTR and GCM

AES is a block cipher; the modes of operation are what people actually use.

CBC is done in plain Go, one block at a time, because it is inherently serial:

go
// src/crypto/internal/fips140/aes/cbc.go:50
func cryptBlocksEncGeneric(b *Block, civ *[BlockSize]byte, dst, src []byte) {
	iv := civ[:]
	for len(src) > 0 {
		// Write the xor to dst, then encrypt in place.
		subtle.XORBytes(dst[:BlockSize], src[:BlockSize], iv)
		encryptBlock(b, dst[:BlockSize], dst[:BlockSize])

		// Move to the next block with this block as the next iv.
		iv = dst[:BlockSize]
		src = src[BlockSize:]
		dst = dst[BlockSize:]

CTR is different: it is parallelisable, because each counter block is independent. Go exploits that with eight-block and four-block assembly routines:

go
// src/crypto/internal/fips140/aes/ctr.go:90
	for len(src) >= 8*BlockSize {
		ctrBlocks8(&c.b, (*[8 * BlockSize]byte)(dst), (*[8 * BlockSize]byte)(src), ivlo, ivhi)
		src = src[8*BlockSize:]
		dst = dst[8*BlockSize:]
		ivlo, ivhi = add128(ivlo, ivhi, 8)
	}

	// The tail can have at most 7 = 4 + 2 + 1 blocks.
	if len(src) >= 4*BlockSize {
		ctrBlocks4(&c.b, (*[4 * BlockSize]byte)(dst), (*[4 * BlockSize]byte)(src), ivlo, ivhi)
		src = src[4*BlockSize:]
		dst = dst[4*BlockSize:]
		ivlo, ivhi = add128(ivlo, ivhi, 4)
	}

and the assembly keeps four independent AES pipelines in flight, which is exactly the shape AES-NI wants:

go
// src/crypto/internal/fips140/aes/ctr_amd64.s:187
	MOVUPS (CX), X0
	AESENC X0, X1
	AESENC X0, X2
	AESENC X0, X3
	AESENC X0, X4
	MOVUPS 16(CX), X0
	AESENC X0, X1
	AESENC X0, X2
	AESENC X0, X3
	AESENC X0, X4

GCM needs both AES and a fast GF(2^128) multiply, which is where PCLMULQDQ (carryless multiply) comes in:

go
// src/crypto/internal/fips140/aes/gcm/gcm_asm.go:33
// Keep in sync with crypto/tls.hasAESGCMHardwareSupport.
var supportsAESGCM = cpu.X86HasAES && cpu.X86HasPCLMULQDQ && cpu.X86HasSSE41 && cpu.X86HasSSSE3 ||
	cpu.ARM64HasAES && cpu.ARM64HasPMULL

var supportsAESGCMEOR3 = supportsAESGCM && cpu.ARM64HasSHA3

On x86 that means AES-NI plus CLMUL plus SSSE3 (for the byte shuffles) plus SSE4.1. Without them, GCM falls back to a bit-by-bit GF(2^128) implementation — dramatically slower, and the reason the AES package’s doc comment is careful to note that on such systems GCM is not constant-time either.

SHA-256 with SHA-NI

SHA-256 gets two hardware paths and one software path, chosen like this:

go
// src/crypto/internal/fips140/sha256/sha256block_amd64.go:14
var useAVX2 = cpu.X86HasAVX && cpu.X86HasAVX2 && cpu.X86HasBMI2
var useSHANI = cpu.X86HasAVX && cpu.X86HasSHA && cpu.X86HasSSE41 && cpu.X86HasSSSE3

func init() {
	impl.Register("sha256", "AVX2", &useAVX2)
	impl.Register("sha256", "SHA-NI", &useSHANI)
}

//go:noescape
func blockAVX2(dig *Digest, p []byte)

//go:noescape
func blockSHANI(dig *Digest, p []byte)

func block(dig *Digest, p []byte) {
	if useSHANI {
		blockSHANI(dig, p)
	} else if useAVX2 {
		blockAVX2(dig, p)
	} else {
		blockGeneric(dig, p)
	}
}

SHA-NI wins when available. The SHA256RNDS2 instruction performs two rounds of the compression function in one go, and the hardware also implements the message schedule partially, in SHA256MSG1 and SHA256MSG2.

The prologue shows the layout. The digest state is shuffled into two XMM registers in a big-endian-friendly arrangement, and the round constants are addressed through a pointer:

go
// src/crypto/internal/fips140/sha256/sha256block_amd64.s:1289
// func blockSHANI(dig *Digest, p []byte)
// Requires: AVX, SHA, SSE2, SSE4.1, SSSE3
TEXT ·blockSHANI(SB), $0-32
	MOVQ    dig+0(FP), DI
	MOVQ    p_base+8(FP), SI
	MOVQ    p_len+16(FP), DX
	SHRQ    $0x06, DX
	SHLQ    $0x06, DX
	CMPQ    DX, $0x00
	JEQ     done
	ADDQ    SI, DX
	VMOVDQU (DI), X1
	VMOVDQU 16(DI), X2
	PSHUFD  $0xb1, X1, X1
	PSHUFD  $0x1b, X2, X2
	VMOVDQA X1, X7
	PALIGNR $0x08, X2, X1
	PBLENDW $0xf0, X7, X2
	VMOVDQA flip_mask<>+0(SB), X8
	LEAQ    K256<>+0(SB), AX

roundLoop:
	// save hash values for addition after rounds
	VMOVDQA X1, X9
	VMOVDQA X2, X10

	// do rounds 0-59
	VMOVDQU     (SI), X0
	PSHUFB      X8, X0
	VMOVDQA     X0, X3
	PADDD       (AX), X0
	SHA256RNDS2 X0, X1, X2
	PSHUFD      $0x0e, X0, X0
	SHA256RNDS2 X0, X2, X1

The PSHUFB X8, X0 is the byte-swap: SHA-256 is defined over big-endian words, x86 is little-endian, so the message block is flipped on the fly using a mask table.

The loop body is structured in groups of four message words. Each group does two SHA256RNDS2 instructions (four rounds), and the message schedule is advanced with SHA256MSG1 and SHA256MSG2:

go
// src/crypto/internal/fips140/sha256/sha256block_amd64.s:1330
	SHA256MSG1  X4, X3
	VMOVDQU     32(SI), X0
	PSHUFB      X8, X0
	VMOVDQA     X0, X5
	PADDD       64(AX), X0
	SHA256RNDS2 X0, X1, X2
	PSHUFD      $0x0e, X0, X0
	SHA256RNDS2 X0, X2, X1
	SHA256MSG1  X5, X4

The PSHUFD $0x0e, X0, X0 between the two SHA256RNDS2 instructions is the trick that makes the four-round group work: SHA256RNDS2 consumes the message schedule word it is given and produces a partial sum, and the shuffle redistributes the intermediate state so that the second instruction in the pair operates on the right half of the state.

The last four rounds are peeled out of the loop, because they are different — no message scheduling, just the final additions:

go
// src/crypto/internal/fips140/sha256/sha256block_amd64.s:1460
	// do rounds 60-63
	VMOVDQA     X6, X0
	PADDD       480(AX), X0
	SHA256RNDS2 X0, X1, X2
	PSHUFD      $0x0e, X0, X0
	SHA256RNDS2 X0, X2, X1

	// add current hash values with previously saved
	PADDD X9, X1
	PADDD X10, X2

	// advance data pointer; loop until buffer empty
	ADDQ $0x40, SI
	CMPQ DX, SI
	JNE  roundLoop

Sixteen SHA256RNDS2 per 64-byte block — sixty-four rounds, two rounds per instruction — plus the message schedule in hardware. On a modern x86 that is roughly a cycle per round, which is the theoretical limit for SHA-256.

The AVX2 path (blockAVX2) is the older trick: it processes four 64-byte blocks at once by transposing them into a 4x4 matrix of words and running the compression function four times in parallel with AVX2 vector operations. It is still useful on machines with AVX2 but no SHA-NI.

The pure-Go fallback, and why it matters

If the CPU has no AES-NI, the package falls back to a table-driven implementation. This is the classic T-table approach:

go
// src/crypto/internal/fips140/aes/aes_generic.go:41
// Encrypt one block from src into dst, using the expanded key xk.
func encryptBlockGeneric(c *blockExpanded, dst, src []byte) {
	checkGenericIsExpected()
	xk := c.enc[:]

	_ = src[15] // early bounds check
	s0 := byteorder.BEUint32(src[0:4])
	s1 := byteorder.BEUint32(src[4:8])
	s2 := byteorder.BEUint32(src[8:12])
	s3 := byteorder.BEUint32(src[12:16])

	// First round just XORs input with key.
	s0 ^= xk[0]
	s1 ^= xk[1]
	s2 ^= xk[2]
	s3 ^= xk[3]

	// Middle rounds shuffle using tables.
	k := 4
	var t0, t1, t2, t3 uint32
	for r := 0; r < c.rounds-1; r++ {
		t0 = xk[k+0] ^ te0[uint8(s0>>24)] ^ te1[uint8(s1>>16)] ^ te2[uint8(s2>>8)] ^ te3[uint8(s3)]
		t1 = xk[k+1] ^ te0[uint8(s1>>24)] ^ te1[uint8(s2>>16)] ^ te2[uint8(s3>>8)] ^ te3[uint8(s0)]
		t2 = xk[k+2] ^ te0[uint8(s2>>24)] ^ te1[uint8(s3>>16)] ^ te2[uint8(s0>>8)] ^ te3[uint8(s1)]
		t3 = xk[k+3] ^ te0[uint8(s3>>24)] ^ te1[uint8(s0>>16)] ^ te2[uint8(s1>>8)] ^ te3[uint8(s2)]
		k += 4
		s0, s1, s2, s3 = t0, t1, t2, t3
	}

The four 256-entry tables (te0 through te3, in const.go) fold SubBytes, ShiftRows and MixColumns into a single table lookup per byte. It is fast — a few hundred MB/s — but the table index depends on the secret key material, so the memory access pattern leaks it through the cache.

That is why the package doc says this:

go
// src/crypto/aes/aes.go:5
// Package aes implements AES encryption (formerly Rijndael), as defined in
// U.S. Federal Information Processing Standards Publication 197.
//
// The AES operations in this package are not implemented using constant-time algorithms.
// An exception is when running on systems with enabled hardware support for AES
// that makes these operations constant-time. Examples include amd64 systems using AES-NI
// extensions and s390x systems using Message-Security-Assist extensions.
// On such systems, when the result of NewCipher is passed to cipher.NewGCM,
// the GHASH operation used by GCM is also constant-time.
package aes

And it is why the assembly build has a tripwire: if the hardware path is available but the generic path runs anyway, the code panics rather than silently losing the constant-time property.

go
// src/crypto/internal/fips140/aes/aes_asm.go:49
// checkGenericIsExpected is called by the variable-time implementation to make
// sure it is not used when hardware support is available. It shouldn't happen,
// but this way it's more evidently correct.
func checkGenericIsExpected() {
	if supportsAES {
		panic("crypto/aes: internal error: using generic implementation despite hardware support")
	}
}

The BoringCrypto path

There is a third implementation, and it is not Go at all. crypto/internal/boring wraps BoringSSL, and the public packages check a build-time constant before doing anything else:

go
// src/crypto/internal/boring/doc.go:10
// Enabled reports whether BoringCrypto is available.
// When enabled is false, all functions in this package panic.
//
// BoringCrypto is only available on linux/amd64 and linux/arm64 systems.
const Enabled = available

boring.Enabled is available, which is false in the default build and true when the tree is built with the boringcrypto build tag:

go
// src/crypto/internal/boring/boring.go:5
//go:build boringcrypto && linux && (amd64 || arm64) && !android && !msan

package boring

/*
// goboringcrypto_linux_amd64.syso references pthread functions.
#cgo LDFLAGS: "-pthread"

#include "goboringcrypto.h"
*/
import "C"
import (
	"crypto/internal/boring/sig"
	_ "crypto/internal/boring/syso"
	"crypto/internal/fips140"
	"internal/stringslite"
	"math/bits"
	"unsafe"
)

const available = true

When it is enabled, crypto/aes.NewCipher returns a BoringSSL cipher instead, and crypto/sha256.New returns a BoringSSL hash. That build is used for FIPS-certified systems that need a validated cryptographic module. It cannot be combined with the Go 1.24+ fips140 mode:

go
// src/crypto/internal/boring/boring.go:35
func init() {
	if fips140.Enabled {
		panic("boringcrypto: cannot use GODEBUG=fips140 with GOEXPERIMENT=boringcrypto")
	}
}

So there are three worlds: the default Go implementation with hardware acceleration, the purego build with no assembly at all, and the BoringCrypto build with a C implementation underneath.

Trade-offs

What the hardware buys:

  • AES: roughly 1 cycle per round on a modern x86, versus roughly 10-20 cycles per round for the T-table fallback. That is a 10-20x gap, and it is why TLS throughput on a server is usually not the bottleneck.
  • SHA-256 with SHA-NI: about 1.5 cycles per byte, versus about 11 for the AVX2 path and about 15-20 for the generic Go implementation.
  • Constant time. AES-NI and SHA-NI have no data-dependent timing. The fallback does.

What it costs:

  • Build complexity. Every primitive has an assembly file, a generic file, and build tags to choose between them, for eight architectures.
  • A dispatch decision at every call. encryptBlock branches on supportsAES — a global bool, so the branch predicts perfectly, but it is a branch.
  • Portability. The purego tag exists because some environments (some emulators, some kernels, some reproducible-build setups) cannot tolerate platform-specific code.

Caveats

The generic fallback is not constant-time. If you are defending against a local attacker who can measure cache timings, the T-table implementation leaks. The package says so, but only in the doc comment, which most people never read.

GCM needs more than AES-NI. supportsAESGCM requires PCLMULQDQ and SSSE3 as well. A CPU with AES-NI but without CLMUL gets fast AES and slow GCM — and the constant-time guarantee for GHASH goes away too.

SHA-NI is not universal. It arrived with Ice Lake server and Alder Lake client. On older Intel and on AMD Zen 1 and 2, the AVX2 path runs instead.

purego is slower than you think. It is not “the same algorithm, less optimised” — it is a different, slower algorithm with different security properties. The FIPS module refuses to run under purego for exactly this reason:

go
// src/crypto/internal/fips140/fips140.go:36
	// The purego tag changes too much of the implementation to claim the
	// validation still applies.
	if puregoEnabled {
		return errors.New("FIPS 140-3 mode is incompatible with the purego build tag")
	}

The assembly is generated. If you are reading aes_amd64.s and cannot follow it, read _asm/standard/aes_amd64.go instead — it is the same program in a language you already know.

BoringCrypto and fips140 are mutually exclusive. Both want to own the cryptographic implementation, and the tree panics at init if you try to have both.

crypto/aes is not where the implementation is. If you are grepping for the AES assembly, look under crypto/internal/fips140/aes/, not crypto/aes/.

Wrapping up

The one thing to remember: crypto/aes and crypto/sha256 are shims. The real implementations are in crypto/internal/fips140, and the dispatch is a pair of CPUID checks made once at start-up: AES-NI for the cipher, SHA-NI for the hash.

The part people get wrong: assuming the pure-Go fallback is equivalent. It is not constant-time, and on a machine without the hardware instructions it is one to two orders of magnitude slower — and GCM falls back to a bit-by-bit GF(2^128) multiply.

What to go do about it: run crypto/internal/impl’s registry to see what your machine actually selected, and compare crypto/aes against the purego build to see the gap on your own hardware. If you are deploying to a fleet, check the oldest CPU’s feature bits, because the fallback is where the constant-time guarantee disappears.

Further reading

  • src/crypto/aes/aes.go — the public shim, and the doc comment about constant time.
  • src/crypto/sha256/sha256.go — the same for SHA-256, including the BoringCrypto branch.
  • src/crypto/internal/fips140/aes/aes_amd64.s — the generated AES-NI assembly: encryptBlockAsm, decryptBlockAsm, expandKeyAsm.
  • src/crypto/internal/fips140/aes/_asm/standard/aes_amd64.go — the Avo program that generates the assembly above.
  • src/crypto/internal/fips140/aes/aes_asm.go — supportsAES, the dispatch, and the checkGenericIsExpected tripwire.
  • src/crypto/internal/fips140/aes/aes_generic.go — the T-table fallback.
  • src/crypto/internal/fips140/aes/const.go — the S-boxes and the te0–te3 tables.
  • src/crypto/internal/fips140/aes/ctr_amd64.s — four- and eight-block parallel CTR.
  • src/crypto/internal/fips140/aes/gcm/gcm_asm.go — the AES-NI plus CLMUL GCM path.
  • src/crypto/internal/fips140/sha256/sha256block_amd64.s — the SHA-NI and AVX2 implementations.
  • src/crypto/internal/fips140/sha256/sha256block_amd64.go — the SHA-256 dispatch.
  • src/internal/cpu/cpu_x86.go — the CPUID feature detection.
  • src/crypto/internal/impl/impl.go — the registry of alternative implementations.
  • src/crypto/internal/boring/boring.go — the BoringCrypto build.

GoInternalsCryptoAssemblyPerformance