diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 56d29f688..f1245deab 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -155,6 +155,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa tailscale.com/util/cibuild from tailscale.com/health+ tailscale.com/util/clientmetric from tailscale.com/net/netmon+ tailscale.com/util/cloudenv from tailscale.com/hostinfo+ + L tailscale.com/util/cpucaps from tailscale.com/tsweb/varz tailscale.com/util/ctxkey from tailscale.com/tsweb+ πŸ’£ tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting L πŸ’£ tailscale.com/util/dirwalk from tailscale.com/metrics diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index effcaddd4..7ea183bcb 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -887,6 +887,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/util/cloudenv from tailscale.com/hostinfo+ tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock LW tailscale.com/util/cmpver from tailscale.com/net/dns+ + L tailscale.com/util/cpucaps from tailscale.com/tsweb/varz tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+ πŸ’£ tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting tailscale.com/util/def from tailscale.com/ipn/localapi diff --git a/cmd/stund/depaware.txt b/cmd/stund/depaware.txt index 65e5361f5..c8268de01 100644 --- a/cmd/stund/depaware.txt +++ b/cmd/stund/depaware.txt @@ -80,6 +80,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar tailscale.com/types/tkatype from tailscale.com/tailcfg+ tailscale.com/types/views from tailscale.com/net/tsaddr+ tailscale.com/util/bufiox from tailscale.com/types/key + L tailscale.com/util/cpucaps from tailscale.com/tsweb/varz tailscale.com/util/ctxkey from tailscale.com/tsweb+ L πŸ’£ tailscale.com/util/dirwalk from tailscale.com/metrics tailscale.com/util/dnsname from tailscale.com/tailcfg diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 9bc347884..810e5a3cf 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -294,6 +294,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/util/clientmetric from tailscale.com/net/netcheck+ tailscale.com/util/cloudenv from tailscale.com/net/dnscache+ tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+ + L tailscale.com/util/cpucaps from tailscale.com/tsweb/varz tailscale.com/util/ctxkey from tailscale.com/types/logger+ πŸ’£ tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting L πŸ’£ tailscale.com/util/dirwalk from tailscale.com/metrics diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index ab74dc680..15c179fb4 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -467,6 +467,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+ tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock+ tailscale.com/util/cmpver from tailscale.com/net/dns+ + L tailscale.com/util/cpucaps from tailscale.com/tsweb/varz tailscale.com/util/ctxkey from tailscale.com/ipn/ipnlocal+ πŸ’£ tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting tailscale.com/util/def from tailscale.com/feature/debugportmapper+ diff --git a/cmd/tailscaled/deps_test.go b/cmd/tailscaled/deps_test.go index e0c34f298..18687d3ec 100644 --- a/cmd/tailscaled/deps_test.go +++ b/cmd/tailscaled/deps_test.go @@ -37,6 +37,18 @@ func TestOmitFavorites(t *testing.T) { }.Check(t) } +func TestOmitCPUCaps(t *testing.T) { + const msg = "unexpected with ts_omit_cpucaps" + deptest.DepChecker{ + GOOS: "linux", + GOARCH: "amd64", + Tags: "ts_omit_cpucaps,ts_include_cli", + BadDeps: map[string]string{ + "tailscale.com/util/cpucaps": msg, + }, + }.Check(t) +} + func TestOmitSSH(t *testing.T) { const msg = "unexpected with ts_omit_ssh" deptest.DepChecker{ diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 9ce9e173d..402c6d133 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -275,6 +275,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar tailscale.com/util/cloudenv from tailscale.com/hostinfo+ tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock LW tailscale.com/util/cmpver from tailscale.com/net/dns+ + L tailscale.com/util/cpucaps from tailscale.com/tsweb/varz tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+ πŸ’£ tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting tailscale.com/util/def from tailscale.com/ipn/localapi diff --git a/feature/buildfeatures/feature_cpucaps_disabled.go b/feature/buildfeatures/feature_cpucaps_disabled.go new file mode 100644 index 000000000..27d5c1ad5 --- /dev/null +++ b/feature/buildfeatures/feature_cpucaps_disabled.go @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by gen.go; DO NOT EDIT. + +//go:build ts_omit_cpucaps + +package buildfeatures + +// HasCPUCaps is whether the binary was built with support for modular feature "CPU capability and GOAMD64 microarchitecture level detection and reporting". +// Specifically, it's whether the binary was NOT built with the "ts_omit_cpucaps" build tag. +// It's a const so it can be used for dead code elimination. +const HasCPUCaps = false diff --git a/feature/buildfeatures/feature_cpucaps_enabled.go b/feature/buildfeatures/feature_cpucaps_enabled.go new file mode 100644 index 000000000..bf7ff9b61 --- /dev/null +++ b/feature/buildfeatures/feature_cpucaps_enabled.go @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by gen.go; DO NOT EDIT. + +//go:build !ts_omit_cpucaps + +package buildfeatures + +// HasCPUCaps is whether the binary was built with support for modular feature "CPU capability and GOAMD64 microarchitecture level detection and reporting". +// Specifically, it's whether the binary was NOT built with the "ts_omit_cpucaps" build tag. +// It's a const so it can be used for dead code elimination. +const HasCPUCaps = true diff --git a/feature/featuretags/featuretags.go b/feature/featuretags/featuretags.go index 824ff73fd..8901a90a8 100644 --- a/feature/featuretags/featuretags.go +++ b/feature/featuretags/featuretags.go @@ -140,6 +140,7 @@ type FeatureMeta struct { }, "completion": {Sym: "Completion", Desc: "CLI shell completion"}, "conn25": {Sym: "Conn25", Desc: "Route traffic for configured domains through connector devices"}, + "cpucaps": {Sym: "CPUCaps", Desc: "CPU capability and GOAMD64 microarchitecture level detection and reporting"}, "completion_scripts": { Sym: "CompletionScripts", Desc: "embed CLI shell completion scripts", Deps: []FeatureTag{"completion"}, diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 1850b6ac0..713a7a9e0 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -270,6 +270,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) tailscale.com/util/cloudenv from tailscale.com/hostinfo+ tailscale.com/util/cloudinfo from tailscale.com/wgengine/magicsock LW tailscale.com/util/cmpver from tailscale.com/net/dns+ + L tailscale.com/util/cpucaps from tailscale.com/tsweb/varz tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+ πŸ’£ tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting tailscale.com/util/def from tailscale.com/ipn/localapi diff --git a/tsweb/varz/goamd64_linux_amd64.go b/tsweb/varz/goamd64_linux_amd64.go index 11964332d..10b4c49ad 100644 --- a/tsweb/varz/goamd64_linux_amd64.go +++ b/tsweb/varz/goamd64_linux_amd64.go @@ -1,88 +1,19 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build !android +//go:build !android && !ts_omit_cpucaps package varz import ( - "bytes" "expvar" - "os" - "runtime/debug" - "strings" + + "tailscale.com/util/cpucaps" ) func init() { - var capable any = hostGOAMD64Level() + var capable any = cpucaps.HostGOAMD64Level() expvar.Publish("gauge_goamd64_capable", expvar.Func(func() any { return capable })) - var compiled any = compiledGOAMD64Level() + var compiled any = cpucaps.CompiledGOAMD64Level() expvar.Publish("gauge_goamd64_compiled", expvar.Func(func() any { return compiled })) } - -// goamd64LevelFlags maps each x86-64 microarchitecture level to the -// /proc/cpuinfo flag names it requires beyond the previous level. -// It matches the per-level feature checks in Go's runtime/asm_amd64.s. -// Note that /proc/cpuinfo spells SSE3 as "pni" and LZCNT as "abm", -// and the kernel clears the AVX and AVX-512 flags when the OS lacks -// XSAVE support, so OSXSAVE needs no separate check. -var goamd64LevelFlags = [...][]string{ - 2: {"cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"}, - 3: {"abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe"}, - 4: {"avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"}, -} - -// hostGOAMD64Level returns the maximum GOAMD64 microarchitecture -// level (1-4) that the host CPU supports, regardless of what level -// this binary was compiled for. It returns 0 on error. -func hostGOAMD64Level() int { - cpuinfo, err := os.ReadFile("/proc/cpuinfo") - if err != nil { - return 0 - } - for line := range bytes.Lines(cpuinfo) { - name, rest, ok := bytes.Cut(line, []byte(":")) - if ok && string(bytes.TrimSpace(name)) == "flags" { - return goamd64Level(strings.Fields(string(rest))) - } - } - return 0 -} - -// goamd64Level returns the maximum GOAMD64 level (1-4) supported by -// a CPU with the given /proc/cpuinfo flags. -func goamd64Level(flags []string) int { - has := make(map[string]bool, len(flags)) - for _, f := range flags { - has[f] = true - } - level := 1 - for next := 2; next < len(goamd64LevelFlags); next++ { - for _, f := range goamd64LevelFlags[next] { - if !has[f] { - return level - } - } - level = next - } - return level -} - -// compiledGOAMD64Level returns the GOAMD64 microarchitecture level -// (1-4) that this binary was compiled for, as recorded in its build -// info. It returns 0 if unknown. -func compiledGOAMD64Level() int { - bi, ok := debug.ReadBuildInfo() - if !ok { - return 0 - } - for _, s := range bi.Settings { - if s.Key == "GOAMD64" { - if v, ok := strings.CutPrefix(s.Value, "v"); ok && len(v) == 1 && v[0] >= '1' && v[0] <= '4' { - return int(v[0] - '0') - } - return 0 - } - } - return 0 -} diff --git a/tsweb/varz/goamd64_linux_amd64_test.go b/tsweb/varz/goamd64_linux_amd64_test.go deleted file mode 100644 index 3f46c1db7..000000000 --- a/tsweb/varz/goamd64_linux_amd64_test.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !android - -package varz - -import ( - "strings" - "testing" -) - -const v4Flags = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov " + - "pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm " + - "constant_tsc rep_good nopl xtopology nonstop_tsc cpuid aperfmperf " + - "tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic " + - "movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor " + - "lahf_lm abm 3dnowprefetch invpcid_single pti fsgsbase tsc_adjust bmi1 " + - "avx2 smep bmi2 erms invpcid avx512f avx512dq rdseed adx smap clflushopt " + - "clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves" - -func withoutFlags(flags string, remove ...string) []string { - var out []string - fs := strings.Fields(flags) -Outer: - for _, f := range fs { - for _, r := range remove { - if f == r { - continue Outer - } - } - out = append(out, f) - } - return out -} - -func TestGoAMD64Level(t *testing.T) { - tests := []struct { - name string - flags []string - want int - }{ - {"v4", withoutFlags(v4Flags), 4}, - {"v3_no_avx512vl", withoutFlags(v4Flags, "avx512vl"), 3}, - {"v3_no_avx512", withoutFlags(v4Flags, "avx512f", "avx512dq", "avx512cd", "avx512bw", "avx512vl"), 3}, - {"v2_no_avx2", withoutFlags(v4Flags, "avx512f", "avx2"), 2}, - {"v2_no_abm", withoutFlags(v4Flags, "avx512bw", "abm"), 2}, - {"v1_no_popcnt", withoutFlags(v4Flags, "avx2", "popcnt"), 1}, - {"v1_no_sse42", withoutFlags(v4Flags, "sse4_2"), 1}, - {"empty", nil, 1}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := goamd64Level(tt.flags); got != tt.want { - t.Errorf("goamd64Level = %d; want %d", got, tt.want) - } - }) - } -} - -func TestHostGOAMD64Level(t *testing.T) { - got := hostGOAMD64Level() - t.Logf("hostGOAMD64Level = %d", got) - if got < 0 || got > 4 { - t.Errorf("out of range [0,4]") - } -} - -func TestCompiledGOAMD64Level(t *testing.T) { - got := compiledGOAMD64Level() - t.Logf("compiledGOAMD64Level = %d", got) - if got < 1 || got > 4 { - t.Errorf("got %d; want in range [1,4] since test binaries have build info", got) - } -} diff --git a/util/cpucaps/caps_freebsd_arm64.go b/util/cpucaps/caps_freebsd_arm64.go new file mode 100644 index 000000000..58398c969 --- /dev/null +++ b/util/cpucaps/caps_freebsd_arm64.go @@ -0,0 +1,20 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && arm64 && !ts_omit_cpucaps + +package cpucaps + +// getisar0 returns the ID_AA64ISAR0_EL1 instruction set attribute +// register. The register is EL1-privileged, but the FreeBSD kernel +// emulates EL0 MRS reads of the ID_AA64* registers; Go's +// runtime/internal/cpu relies on the same emulation on this +// platform, so any running Go binary proves it is available. +func getisar0() uint64 + +// hostCapsArch supplements x/sys/cpu, which has no arm64 feature +// detection on FreeBSD (it reports only the baseline FP/ASIMD +// features there). +func hostCapsArch() Caps { + return capsFromISAR0(getisar0()) +} diff --git a/util/cpucaps/caps_freebsd_arm64.s b/util/cpucaps/caps_freebsd_arm64.s new file mode 100644 index 000000000..e03a539ef --- /dev/null +++ b/util/cpucaps/caps_freebsd_arm64.s @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build freebsd && arm64 && gc && !ts_omit_cpucaps + +#include "textflag.h" + +// func getisar0() uint64 +TEXT Β·getisar0(SB), NOSPLIT, $0-8 + // get Instruction Set Attributes 0 into R0 + MRS ID_AA64ISAR0_EL1, R0 + MOVD R0, ret+0(FP) + RET diff --git a/util/cpucaps/caps_other.go b/util/cpucaps/caps_other.go new file mode 100644 index 000000000..66e1dc597 --- /dev/null +++ b/util/cpucaps/caps_other.go @@ -0,0 +1,11 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !(freebsd && arm64) && !ts_omit_cpucaps + +package cpucaps + +// hostCapsArch returns capabilities not detectable via x/sys/cpu. +// On most platforms x/sys/cpu is complete and this returns 0; see +// caps_freebsd_arm64.go for the exception. +func hostCapsArch() Caps { return 0 } diff --git a/util/cpucaps/cpucaps.go b/util/cpucaps/cpucaps.go new file mode 100644 index 000000000..8642de7d7 --- /dev/null +++ b/util/cpucaps/cpucaps.go @@ -0,0 +1,133 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package cpucaps reports capabilities of the host CPU that Go +// generated code and assembly can make use of, along with the +// x86-64 microarchitecture level the running binary was compiled +// for. +// +// Detection is lazy (no init-time work) and sources its data from +// golang.org/x/sys/cpu, which uses raw CPUID on x86 and in-process +// auxv/HWCAP (with trapped MRS register reads as fallback) on +// linux/arm64, so normally no files are read or parsed (x/sys/cpu +// retains /proc fallbacks on linux/arm64 that are unreachable when +// built with Go 1.21+). The host GOAMD64 level is derived from CPUID +// feature bits, mirroring the per-level checks in Go's +// runtime/asm_amd64.s: raw CPUID is what the Go runtime itself +// dispatches on, so it is the ground truth for what level a binary +// could run at on this machine. A linux/amd64 test cross-checks the +// derivation against the exact /proc/cpuinfo flag lists. +// +// All key target platforms are supported without external OS +// dependencies (no files parsed, no processes executed): +// +// - amd64 (linux, windows, darwin, freebsd, android): raw CPUID +// - linux+android/arm64: in-process auxv HWCAP, falling back to +// kernel-emulated MRS reads of the ID_AA64* registers +// - darwin+ios/arm64: sysctl syscalls (the ios GOOS satisfies the +// darwin build constraint in x/sys/cpu) +// - windows/arm64: IsProcessorFeaturePresent +// - freebsd/arm64: kernel-emulated MRS read of ID_AA64ISAR0_EL1, +// as Go's runtime/internal/cpu does on this platform (x/sys/cpu +// does not yet support it) +// +// Building with the ts_omit_cpucaps tag removes all detection code: +// the detection functions then return zero values. The Caps type and +// its names remain available for decoding recorded values. +package cpucaps + +import ( + "strconv" + "strings" +) + +// Caps is a bitmask of host CPU capabilities that Go's runtime and +// standard library dispatch on, normalized across architectures +// (e.g. CapAES means x86 AES-NI or arm64 FEAT_AES). +// +// Bits are append-only: never reuse or renumber a bit, even for +// features that become obsolete, so recorded values keep their +// meaning. Keep bit positions below 53 so values survive JSON +// round-trips through IEEE-754 consumers. +type Caps uint64 + +const ( + CapAES Caps = 1 << iota // x86 AES-NI / arm64 FEAT_AES + CapCLMUL // carry-less multiply for GHASH/GCM: x86 PCLMULQDQ / arm64 FEAT_PMULL + CapSHA2 // SHA-256 instructions: arm64 FEAT_SHA256 (x86 SHA-NI not yet exposed by x/sys/cpu) + CapSHA512 // SHA-512 instructions: arm64 FEAT_SHA512 + CapCRC32 // arm64 FEAT_CRC32 (on x86, CRC32 is implied by GOAMD64 level >= 2 via SSE4.2) + CapADX // x86 multi-precision add-carry (math/big) + CapLSE // arm64 FEAT_LSE atomics + CapAVX // x86 AVX, incl. OS XSAVE support + CapAVX2 // x86 AVX2, incl. OS XSAVE support (GOAMD64 v3 vector baseline) + CapAVX512 // x86 AVX-512 F+BW+CD+DQ+VL, the GOAMD64 v4 usable subset, incl. OS ZMM state support + CapVAES // x86 VAES: vectorized AES over 256/512-bit lanes + CapVPCLMUL // x86 VPCLMULQDQ: vectorized carry-less multiply + CapGFNI // x86 Galois field new instructions + + capMax +) + +var capNames = [...]string{ + "aes", "clmul", "sha2", "sha512", "crc32", "adx", "lse", + "avx", "avx2", "avx512", "vaes", "vpclmul", "gfni", +} + +// String returns a pipe-separated lower-case list of the set +// capability names, e.g. "aes|clmul|lse", or "" if none are set. +// Set bits without a known name (e.g. in a value recorded by a +// newer binary) are reported as a single hex remainder, e.g. +// "aes|unknown(0x4000)". +func (c Caps) String() string { + var sb strings.Builder + for i, name := range capNames { + if c&(1< 0 { + sb.WriteByte('|') + } + sb.WriteString(name) + } + } + if unknown := c &^ (capMax - 1); unknown != 0 { + if sb.Len() > 0 { + sb.WriteByte('|') + } + sb.WriteString("unknown(0x") + sb.WriteString(strconv.FormatUint(uint64(unknown), 16)) + sb.WriteString(")") + } + return sb.String() +} + +// capsFromISAR0 returns the capabilities encoded in the arm64 +// ID_AA64ISAR0_EL1 instruction set attribute register. Field values +// follow the ARM Architecture Reference Manual and match the +// decoding in x/sys/cpu's parseARM64SystemRegisters and Go's +// runtime/internal/cpu. It is used on platforms where x/sys/cpu +// lacks arm64 feature detection (currently FreeBSD). +func capsFromISAR0(isar0 uint64) (c Caps) { + extract := func(lo uint) uint64 { return (isar0 >> lo) & 0xf } + + switch extract(4) { // AES field + case 2: + c |= CapCLMUL + fallthrough + case 1: + c |= CapAES + } + switch extract(12) { // SHA2 field + case 2: + c |= CapSHA512 + fallthrough + case 1: + c |= CapSHA2 + } + if extract(16) >= 1 { // CRC32 field + c |= CapCRC32 + } + if extract(20) >= 2 { // Atomic field + c |= CapLSE + } + return c +} diff --git a/util/cpucaps/cpucaps_test.go b/util/cpucaps/cpucaps_test.go new file mode 100644 index 000000000..5b4cd023c --- /dev/null +++ b/util/cpucaps/cpucaps_test.go @@ -0,0 +1,148 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package cpucaps + +import ( + "strings" + "testing" +) + +// goamd64LevelFlags maps each x86-64 microarchitecture level to the +// /proc/cpuinfo flag names it requires beyond the previous level. +// It matches the per-level feature checks in Go's runtime/asm_amd64.s +// and serves as the exact reference that the x/sys/cpu-derived +// implementation is checked against. Note that /proc/cpuinfo spells +// SSE3 as "pni" and LZCNT as "abm", and the kernel clears the AVX +// and AVX-512 flags when the OS lacks XSAVE support, so OSXSAVE +// needs no separate check. +var goamd64LevelFlags = [...][]string{ + 2: {"cx16", "lahf_lm", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3"}, + 3: {"abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe"}, + 4: {"avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"}, +} + +// goamd64LevelFromFlags returns the maximum GOAMD64 level (1-4) +// supported by a CPU with the given /proc/cpuinfo flags. +func goamd64LevelFromFlags(flags []string) int { + has := make(map[string]bool, len(flags)) + for _, f := range flags { + has[f] = true + } + level := 1 + for next := 2; next < len(goamd64LevelFlags); next++ { + for _, f := range goamd64LevelFlags[next] { + if !has[f] { + return level + } + } + level = next + } + return level +} + +const v4Flags = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov " + + "pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm " + + "constant_tsc rep_good nopl xtopology nonstop_tsc cpuid aperfmperf " + + "tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic " + + "movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor " + + "lahf_lm abm 3dnowprefetch invpcid_single pti fsgsbase tsc_adjust bmi1 " + + "avx2 smep bmi2 erms invpcid avx512f avx512dq rdseed adx smap clflushopt " + + "clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves" + +func withoutFlags(flags string, remove ...string) []string { + var out []string + fs := strings.Fields(flags) +Outer: + for _, f := range fs { + for _, r := range remove { + if f == r { + continue Outer + } + } + out = append(out, f) + } + return out +} + +func TestGoAMD64LevelFromFlags(t *testing.T) { + tests := []struct { + name string + flags []string + want int + }{ + {"v4", withoutFlags(v4Flags), 4}, + {"v3_no_avx512vl", withoutFlags(v4Flags, "avx512vl"), 3}, + {"v3_no_avx512", withoutFlags(v4Flags, "avx512f", "avx512dq", "avx512cd", "avx512bw", "avx512vl"), 3}, + {"v2_no_avx2", withoutFlags(v4Flags, "avx512f", "avx2"), 2}, + {"v2_no_abm", withoutFlags(v4Flags, "avx512bw", "abm"), 2}, + {"v1_no_popcnt", withoutFlags(v4Flags, "avx2", "popcnt"), 1}, + {"v1_no_sse42", withoutFlags(v4Flags, "sse4_2"), 1}, + {"empty", nil, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := goamd64LevelFromFlags(tt.flags); got != tt.want { + t.Errorf("goamd64LevelFromFlags = %d; want %d", got, tt.want) + } + }) + } +} + +func TestCapNamesInSync(t *testing.T) { + if want := Caps(1) << len(capNames); capMax != want { + t.Errorf("capMax = %#x, want %#x; capNames and the Cap constants are out of sync", uint64(capMax), uint64(want)) + } +} + +func TestCapsFromISAR0(t *testing.T) { + // Field values per the ARM ARM for ID_AA64ISAR0_EL1: + // AES [7:4], SHA2 [15:12], CRC32 [19:16], Atomic [23:20]. + mk := func(aes, sha2, crc32, atomic uint64) uint64 { + return aes<<4 | sha2<<12 | crc32<<16 | atomic<<20 + } + tests := []struct { + name string + isar0 uint64 + want Caps + }{ + {"none", 0, 0}, + {"aes_only", mk(1, 0, 0, 0), CapAES}, + {"aes_pmull", mk(2, 0, 0, 0), CapAES | CapCLMUL}, + {"sha256", mk(0, 1, 0, 0), CapSHA2}, + {"sha512", mk(0, 2, 0, 0), CapSHA2 | CapSHA512}, + {"crc32", mk(0, 0, 1, 0), CapCRC32}, + {"atomic_v81", mk(0, 0, 0, 2), CapLSE}, + {"atomic_lse128", mk(0, 0, 0, 3), CapLSE}, + {"atomic_reserved1", mk(0, 0, 0, 1), 0}, + {"neoverse_n1", mk(2, 2, 1, 2), CapAES | CapCLMUL | CapSHA2 | CapSHA512 | CapCRC32 | CapLSE}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := capsFromISAR0(tt.isar0); got != tt.want { + t.Errorf("capsFromISAR0(%#x) = %v; want %v", tt.isar0, got, tt.want) + } + }) + } +} + +func TestCapsString(t *testing.T) { + tests := []struct { + c Caps + want string + }{ + {0, ""}, + {CapAES, "aes"}, + {CapAES | CapCLMUL | CapLSE, "aes|clmul|lse"}, + {CapSHA512 | CapADX, "sha512|adx"}, + {CapAVX | CapAVX2 | CapAVX512, "avx|avx2|avx512"}, + {CapVAES | CapVPCLMUL | CapGFNI, "vaes|vpclmul|gfni"}, + {capMax, "unknown(0x2000)"}, + {CapAES | capMax | capMax<<1, "aes|unknown(0x6000)"}, + } + for _, tt := range tests { + if got := tt.c.String(); got != tt.want { + t.Errorf("Caps(%#x).String() = %q; want %q", uint64(tt.c), got, tt.want) + } + } +} diff --git a/util/cpucaps/detect.go b/util/cpucaps/detect.go new file mode 100644 index 000000000..800d9fa5d --- /dev/null +++ b/util/cpucaps/detect.go @@ -0,0 +1,139 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_cpucaps + +package cpucaps + +import ( + "runtime" + "runtime/debug" + "strings" + "sync" + + "golang.org/x/sys/cpu" +) + +// Host returns the capabilities of the host CPU. +// +// The result is computed once on first call from x/sys/cpu feature +// bits that were already initialized at process start; subsequent +// calls are free. +func Host() Caps { return hostCaps() } + +var hostCaps = sync.OnceValue(func() (c Caps) { + if runtime.GOARCH == "amd64" || runtime.GOARCH == "386" { + x := cpu.X86 + if x.HasAES { + c |= CapAES + } + if x.HasPCLMULQDQ { + c |= CapCLMUL + } + if x.HasADX { + c |= CapADX + } + if x.HasAVX { + c |= CapAVX + } + if x.HasAVX2 { + c |= CapAVX2 + } + if x.HasAVX512F && x.HasAVX512BW && x.HasAVX512CD && x.HasAVX512DQ && x.HasAVX512VL { + c |= CapAVX512 + } + // Note: x/sys/cpu detects VAES/VPCLMULQDQ/GFNI only when + // the OS supports AVX-512 state, so hybrid parts that pair + // these with only 256-bit vectors (e.g. Alder Lake E-core + // configs) are not reported. + if x.HasAVX512VAES { + c |= CapVAES + } + if x.HasAVX512VPCLMULQDQ { + c |= CapVPCLMUL + } + if x.HasAVX512GFNI { + c |= CapGFNI + } + // x/sys/cpu does not expose x86 SHA-NI; CapSHA2 is + // currently reported on arm64 only. + } + if runtime.GOARCH == "arm64" { + a := cpu.ARM64 + if a.HasAES { + c |= CapAES + } + if a.HasPMULL { + c |= CapCLMUL + } + if a.HasSHA2 { + c |= CapSHA2 + } + if a.HasSHA512 { + c |= CapSHA512 + } + if a.HasCRC32 { + c |= CapCRC32 + } + if a.HasATOMICS { + c |= CapLSE + } + } + c |= hostCapsArch() + return c +}) + +// HostGOAMD64Level returns the maximum GOAMD64 microarchitecture +// level (1-4) that the host CPU supports per its CPUID feature bits, +// regardless of what level this binary was compiled for. It returns +// 0 if the host is not x86-64. +func HostGOAMD64Level() int { return hostGOAMD64Level() } + +var hostGOAMD64Level = sync.OnceValue(goamd64LevelFromXSysCPU) + +// goamd64LevelFromXSysCPU returns the maximum GOAMD64 level (1-4) +// derived from x/sys/cpu feature bits, or 0 if not running on amd64. +// It mirrors the per-level feature checks in Go's runtime/asm_amd64.s, +// with one gap: x/sys/cpu does not expose LAHF/SAHF (level 2) or +// MOVBE, LZCNT and F16C (level 3), so those are assumed present when +// the remaining flags of their level are; no known x86-64 CPU has +// the checked subset without them. TestCPUInfoMatchesXSysCPU +// cross-checks this assumption against the exact /proc/cpuinfo flag +// lists on linux/amd64. +func goamd64LevelFromXSysCPU() int { + if runtime.GOARCH != "amd64" { + return 0 + } + x := cpu.X86 + if !(x.HasCX16 && x.HasPOPCNT && x.HasSSE3 && x.HasSSSE3 && x.HasSSE41 && x.HasSSE42) { + return 1 + } + if !(x.HasAVX && x.HasAVX2 && x.HasBMI1 && x.HasBMI2 && x.HasFMA && x.HasOSXSAVE) { + return 2 + } + if !(x.HasAVX512F && x.HasAVX512BW && x.HasAVX512CD && x.HasAVX512DQ && x.HasAVX512VL) { + return 3 + } + return 4 +} + +// CompiledGOAMD64Level returns the GOAMD64 microarchitecture level +// (1-4) that this binary was compiled for, as recorded in its build +// info. It returns 0 if unknown or if the binary is not amd64. +func CompiledGOAMD64Level() int { return compiledGOAMD64Level() } + +var compiledGOAMD64Level = sync.OnceValue(func() int { + bi, ok := debug.ReadBuildInfo() + if !ok { + return 0 + } + for _, s := range bi.Settings { + if s.Key == "GOAMD64" { + if v, ok := strings.CutPrefix(s.Value, "v"); ok && len(v) == 1 && v[0] >= '1' && v[0] <= '4' { + return int(v[0] - '0') + } + return 0 + } + } + return 0 +}) diff --git a/util/cpucaps/detect_omit.go b/util/cpucaps/detect_omit.go new file mode 100644 index 000000000..6403a2e1b --- /dev/null +++ b/util/cpucaps/detect_omit.go @@ -0,0 +1,15 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ts_omit_cpucaps + +package cpucaps + +// Host returns 0 in binaries built with ts_omit_cpucaps. +func Host() Caps { return 0 } + +// HostGOAMD64Level returns 0 in binaries built with ts_omit_cpucaps. +func HostGOAMD64Level() int { return 0 } + +// CompiledGOAMD64Level returns 0 in binaries built with ts_omit_cpucaps. +func CompiledGOAMD64Level() int { return 0 } diff --git a/util/cpucaps/detect_test.go b/util/cpucaps/detect_test.go new file mode 100644 index 000000000..c76f7b56c --- /dev/null +++ b/util/cpucaps/detect_test.go @@ -0,0 +1,46 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_cpucaps + +package cpucaps + +import ( + "runtime" + "testing" +) + +func TestHostGOAMD64Level(t *testing.T) { + got := HostGOAMD64Level() + t.Logf("HostGOAMD64Level = %d", got) + if runtime.GOARCH == "amd64" { + if got < 1 || got > 4 { + t.Errorf("got %d; want in range [1,4] on amd64", got) + } + } else if got != 0 { + t.Errorf("got %d; want 0 on non-amd64", got) + } +} + +func TestCompiledGOAMD64Level(t *testing.T) { + got := CompiledGOAMD64Level() + t.Logf("CompiledGOAMD64Level = %d", got) + if runtime.GOARCH == "amd64" { + if got < 1 || got > 4 { + t.Errorf("got %d; want in range [1,4] since test binaries have build info", got) + } + } else if got != 0 { + t.Errorf("got %d; want 0 on non-amd64", got) + } +} + +func TestHostCaps(t *testing.T) { + c := Host() + t.Logf("Host caps = %#x (%v)", uint64(c), c) + if c >= capMax { + t.Errorf("unexpected bits set beyond capMax: %#x", uint64(c)) + } + if Host() != c { + t.Errorf("Host not stable across calls") + } +} diff --git a/util/cpucaps/goamd64_linux_amd64_test.go b/util/cpucaps/goamd64_linux_amd64_test.go new file mode 100644 index 000000000..7027a479e --- /dev/null +++ b/util/cpucaps/goamd64_linux_amd64_test.go @@ -0,0 +1,45 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && amd64 && !android && !ts_omit_cpucaps + +package cpucaps + +import ( + "bytes" + "os" + "strings" + "testing" +) + +// TestCPUInfoMatchesXSysCPU cross-checks the x/sys/cpu CPUID +// derivation of the host GOAMD64 level against the exact +// /proc/cpuinfo flag lists. The x/sys/cpu path assumes LAHF/SAHF, +// MOVBE, LZCNT and F16C accompany the rest of their level's flags; a +// mismatch here either means we found real hardware where that +// assumption is wrong and HostGOAMD64Level would over-report, or +// that the kernel's view diverges from raw CPUID (e.g. a clearcpuid= +// boot parameter or a hypervisor filtering /proc/cpuinfo but not +// guest CPUID). +func TestCPUInfoMatchesXSysCPU(t *testing.T) { + cpuinfo, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + t.Skipf("skipping: %v", err) + } + exact := -1 + for line := range bytes.Lines(cpuinfo) { + name, rest, ok := bytes.Cut(line, []byte(":")) + if ok && string(bytes.TrimSpace(name)) == "flags" { + exact = goamd64LevelFromFlags(strings.Fields(string(rest))) + break + } + } + if exact == -1 { + t.Skip("skipping: no flags line in /proc/cpuinfo") + } + approx := HostGOAMD64Level() + t.Logf("cpuinfo=%d x/sys/cpu=%d", exact, approx) + if exact != approx { + t.Errorf("/proc/cpuinfo level %d != x/sys/cpu level %d", exact, approx) + } +}