Repository: cilium/ebpf
Pull request #776 (2022-08-31 16:36:22)

features: reuse internal.FeatureTest instead of open coding probes
This PR changes `features` to use `internal.FeatureTest` instead of manually implementing a "cache struct" for the various kinds of probes. I've mentioned this in previous discussions of the package and finally got round do implementing it.

The advantages are:

- All feature probes use the same wrapping / caching behaviour. In the past we returned bare errors from `features`, and there are subtle differences in what errors are cached between `features` and `internal.FeatureTest`.
- Probes using `FeatureMatrix` can run concurrently with each other. So `HaveProgramType(SocketFilter)` doesn't block `HaveProgramType(Kprobe)` and vice versa. This is important to avoid deadlocks if feature tests call other feature tests.
- ErrNotSupported returned from `features` now include minimum kernel versions and feature names, like the rest of the library.
- It allows to eliminate the kludgy `Max()` methods on a bunch of constants.

I've also cut out copy pasted code that tried to avoid an import cycle with `ebpf`. That ship has now firmly sailed, since we use `ebpf` types in the public API of `features`.

ti-mo: Rebased this, giving this another round.

Patch:
diff --git a/btf/btf.go b/btf/btf.go
index 6716fe7..793701b 100644
--- a/btf/btf.go
+++ b/btf/btf.go
@@ -689,7 +689,7 @@ func marshalBTF(types interface{}, strings []byte, bo binary.ByteOrder) []byte {
 
 // haveBTF attempts to load a BTF blob containing an Int. It should pass on any
 // kernel that supports BPF_BTF_LOAD.
-var haveBTF = internal.FeatureTest("BTF", "4.18", func() error {
+var haveBTF = internal.NewFeatureTest("BTF", "4.18", func() error {
 	var (
 		types struct {
 			Integer btfType
@@ -719,7 +719,7 @@ var haveBTF = internal.FeatureTest("BTF", "4.18", func() error {
 // haveMapBTF attempts to load a minimal BTF blob containing a Var. It is
 // used as a proxy for .bss, .data and .rodata map support, which generally
 // come with a Var and Datasec. These were introduced in Linux 5.2.
-var haveMapBTF = internal.FeatureTest("Map BTF (Var/Datasec)", "5.2", func() error {
+var haveMapBTF = internal.NewFeatureTest("Map BTF (Var/Datasec)", "5.2", func() error {
 	if err := haveBTF(); err != nil {
 		return err
 	}
@@ -760,7 +760,7 @@ var haveMapBTF = internal.FeatureTest("Map BTF (Var/Datasec)", "5.2", func() err
 // haveProgBTF attempts to load a BTF blob containing a Func and FuncProto. It
 // is used as a proxy for ext_info (func_info) support, which depends on
 // Func(Proto) by definition.
-var haveProgBTF = internal.FeatureTest("Program BTF (func/line_info)", "5.0", func() error {
+var haveProgBTF = internal.NewFeatureTest("Program BTF (func/line_info)", "5.0", func() error {
 	if err := haveBTF(); err != nil {
 		return err
 	}
@@ -795,7 +795,7 @@ var haveProgBTF = internal.FeatureTest("Program BTF (func/line_info)", "5.0", fu
 	return nil
 })
 
-var haveFuncLinkage = internal.FeatureTest("BTF func linkage", "5.6", func() error {
+var haveFuncLinkage = internal.NewFeatureTest("BTF func linkage", "5.6", func() error {
 	if err := haveProgBTF(); err != nil {
 		return err
 	}
diff --git a/features/errors.go b/features/errors.go
deleted file mode 100644
index 87e7fa6..0000000
--- a/features/errors.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package features
-
-import (
-	"errors"
-	"fmt"
-
-	"github.com/cilium/ebpf"
-)
-
-// wrapProbeErrors wraps err to prevent callers from directly comparing
-// it to exported sentinels. Error rewriting in Go can be implemented by
-// deferring a closure over a named error return variable. This gives the
-// closure access to the stack space where the return value will be written,
-// allowing it to intercept and rewrite all returns, regardless of whether
-// or not the return statements use the named return variable.
-//
-//	func foo() (err error) {
-//	  defer func() {
-//	    err = wrapProbeErrors(err)
-//	  }
-//	  return errors.New("this error will be wrapped")
-//	}
-func wrapProbeErrors(err error) error {
-	if err == nil {
-		return nil
-	}
-
-	// Wrap all errors to prevent them from being compared directly
-	// to exported sentinels by the caller.
-	errStr := "%w"
-
-	if !errors.Is(err, ebpf.ErrNotSupported) {
-		// Wrap unexpected errors with an appropriate error string.
-		errStr = "unexpected error during feature probe: %w"
-	}
-
-	return fmt.Errorf(errStr, err)
-}
diff --git a/features/map.go b/features/map.go
index c9a70df..093f37e 100644
--- a/features/map.go
+++ b/features/map.go
@@ -2,166 +2,228 @@ package features
 
 import (
 	"errors"
+	"fmt"
 	"os"
-	"sync"
 	"unsafe"
 
 	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/sys"
 	"github.com/cilium/ebpf/internal/unix"
 )
 
-func init() {
-	mc.mapTypes = make(map[ebpf.MapType]error)
-	mc.mapFlags = make(map[MapFlags]error)
-}
-
-var (
-	mc mapCache
-)
-
-type mapCache struct {
-	sync.Mutex
-	mapTypes map[ebpf.MapType]error
-	mapFlags map[MapFlags]error
+// HaveMapType probes the running kernel for the availability of the specified map type.
+//
+// See the package documentation for the meaning of the error return value.
+func HaveMapType(mt ebpf.MapType) error {
+	return haveMapTypeMatrix.Result(mt)
 }
 
-func createMapTypeAttr(mt ebpf.MapType) *sys.MapCreateAttr {
-	a := &sys.MapCreateAttr{
-		MapType:    sys.MapType(mt),
-		KeySize:    4,
+func probeCgroupStorageMap(mt sys.MapType) error {
+	// keySize needs to be sizeof(struct{u32 + u64}) = 12 (+ padding = 16)
+	// by using unsafe.Sizeof(int) we are making sure that this works on 32bit and 64bit archs
+	return createMap(&sys.MapCreateAttr{
+		MapType:    mt,
 		ValueSize:  4,
-		MaxEntries: 1,
-	}
-
-	// switch on map types to generate correct MapCreateAttr
-	switch mt {
-	case ebpf.StackTrace:
-		// valueSize needs to be sizeof(uint64)
-		a.ValueSize = 8
-	case ebpf.LPMTrie:
-		// keySize and valueSize need to be sizeof(struct{u32 + u8}) + 1 + padding = 8
-		// BPF_F_NO_PREALLOC needs to be set
-		// checked at allocation time for lpm_trie maps
-		a.KeySize = 8
-		a.ValueSize = 8
-		a.MapFlags = unix.BPF_F_NO_PREALLOC
-	case ebpf.ArrayOfMaps, ebpf.HashOfMaps:
-		// assign invalid innerMapFd to pass validation check
-		// will return EBADF
-		a.InnerMapFd = ^uint32(0)
-	case ebpf.CGroupStorage, ebpf.PerCPUCGroupStorage:
-		// keySize needs to be sizeof(struct{u32 + u64}) = 12 (+ padding = 16)
-		// by using unsafe.Sizeof(int) we are making sure that this works on 32bit and 64bit archs
-		// checked at allocation time
-		var align int
-		a.KeySize = uint32(8 + unsafe.Sizeof(align))
-		a.MaxEntries = 0
-	case ebpf.Queue, ebpf.Stack:
-		// keySize needs to be 0, see alloc_check for queue and stack maps
-		a.KeySize = 0
-	case ebpf.RingBuf:
-		// keySize and valueSize need to be 0
-		// maxEntries needs to be power of 2 and PAGE_ALIGNED
-		// checked at allocation time
-		a.KeySize = 0
-		a.ValueSize = 0
-		a.MaxEntries = uint32(os.Getpagesize())
-	case ebpf.SkStorage, ebpf.InodeStorage, ebpf.TaskStorage:
-		// maxEntries needs to be 0
-		// BPF_F_NO_PREALLOC needs to be set
-		// btf* fields need to be set
-		// see alloc_check for local_storage map types
-		a.MaxEntries = 0
-		a.MapFlags = unix.BPF_F_NO_PREALLOC
-		a.BtfKeyTypeId = 1   // BTF_KIND_INT
-		a.BtfValueTypeId = 3 // BTF_KIND_ARRAY
-		a.BtfFd = ^uint32(0)
-	case ebpf.StructOpsMap:
-		// StructOps requires setting a vmlinux type id, but id 1 will always
-		// resolve to some type of integer. This will cause ENOTSUPP.
-		a.BtfVmlinuxValueTypeId = 1
-	}
-
-	return a
+		KeySize:    uint32(8 + unsafe.Sizeof(int(0))),
+		MaxEntries: 0,
+	})
 }
 
-// HaveMapType probes the running kernel for the availability of the specified map type.
-//
-// See the package documentation for the meaning of the error return value.
-func HaveMapType(mt ebpf.MapType) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	if err := validateMaptype(mt); err != nil {
-		return err
+func probeStorageMap(mt sys.MapType) error {
+	// maxEntries needs to be 0
+	// BPF_F_NO_PREALLOC needs to be set
+	// btf* fields need to be set
+	// see alloc_check for local_storage map types
+	err := createMap(&sys.MapCreateAttr{
+		MapType:        mt,
+		KeySize:        4,
+		ValueSize:      4,
+		MaxEntries:     0,
+		MapFlags:       unix.BPF_F_NO_PREALLOC,
+		BtfKeyTypeId:   1,
+		BtfValueTypeId: 1,
+		BtfFd:          ^uint32(0),
+	})
+	if errors.Is(err, unix.EBADF) {
+		// Triggered by BtfFd.
+		return nil
 	}
-
-	return haveMapType(mt)
+	return err
 }
 
-func validateMaptype(mt ebpf.MapType) error {
-	if mt > mt.Max() {
-		return os.ErrInvalid
+func probeNestedMap(mt sys.MapType) error {
+	// assign invalid innerMapFd to pass validation check
+	// will return EBADF
+	err := probeMap(&sys.MapCreateAttr{
+		MapType:    mt,
+		InnerMapFd: ^uint32(0),
+	})
+	if errors.Is(err, unix.EBADF) {
+		return nil
 	}
-	return nil
+	return err
 }
 
-func haveMapType(mt ebpf.MapType) error {
-	mc.Lock()
-	defer mc.Unlock()
-	err, ok := mc.mapTypes[mt]
-	if ok {
-		return err
+func probeMap(attr *sys.MapCreateAttr) error {
+	if attr.KeySize == 0 {
+		attr.KeySize = 4
+	}
+	if attr.ValueSize == 0 {
+		attr.ValueSize = 4
 	}
+	attr.MaxEntries = 1
+	return createMap(attr)
+}
 
-	fd, err := sys.MapCreate(createMapTypeAttr(mt))
+func createMap(attr *sys.MapCreateAttr) error {
+	fd, err := sys.MapCreate(attr)
 	if err == nil {
 		fd.Close()
+		return nil
 	}
 
 	switch {
-	// For nested and storage map types we accept EBADF as indicator that these maps are supported
-	case errors.Is(err, unix.EBADF):
-		if isMapOfMaps(mt) || isStorageMap(mt) {
-			err = nil
-		}
-
-	// ENOTSUPP means the map type is at least known to the kernel.
-	case errors.Is(err, sys.ENOTSUPP):
-		if mt == ebpf.StructOpsMap {
-			err = nil
-		}
-
 	// EINVAL occurs when attempting to create a map with an unknown type.
 	// E2BIG occurs when MapCreateAttr contains non-zero bytes past the end
 	// of the struct known by the running kernel, meaning the kernel is too old
 	// to support the given map type.
 	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
-		err = ebpf.ErrNotSupported
+		return ebpf.ErrNotSupported
 	}
 
-	mc.mapTypes[mt] = err
-
 	return err
 }
 
-func isMapOfMaps(mt ebpf.MapType) bool {
-	switch mt {
-	case ebpf.ArrayOfMaps, ebpf.HashOfMaps:
-		return true
-	}
-	return false
+var haveMapTypeMatrix = internal.FeatureMatrix[ebpf.MapType]{
+	ebpf.Hash:           {Version: "3.19"},
+	ebpf.Array:          {Version: "3.19"},
+	ebpf.ProgramArray:   {Version: "4.2"},
+	ebpf.PerfEventArray: {Version: "4.3"},
+	ebpf.PerCPUHash:     {Version: "4.6"},
+	ebpf.PerCPUArray:    {Version: "4.6"},
+	ebpf.StackTrace: {
+		Version: "4.6",
+		Fn: func() error {
+			return probeMap(&sys.MapCreateAttr{
+				MapType:   sys.BPF_MAP_TYPE_STACK_TRACE,
+				ValueSize: 8, // sizeof(uint64)
+			})
+		},
+	},
+	ebpf.CGroupArray: {Version: "4.8"},
+	ebpf.LRUHash:     {Version: "4.10"},
+	ebpf.LRUCPUHash:  {Version: "4.10"},
+	ebpf.LPMTrie: {
+		Version: "4.11",
+		Fn: func() error {
+			// keySize and valueSize need to be sizeof(struct{u32 + u8}) + 1 + padding = 8
+			// BPF_F_NO_PREALLOC needs to be set
+			return probeMap(&sys.MapCreateAttr{
+				MapType:   sys.BPF_MAP_TYPE_LPM_TRIE,
+				KeySize:   8,
+				ValueSize: 8,
+				MapFlags:  unix.BPF_F_NO_PREALLOC,
+			})
+		},
+	},
+	ebpf.ArrayOfMaps: {
+		Version: "4.12",
+		Fn:      func() error { return probeNestedMap(sys.BPF_MAP_TYPE_ARRAY_OF_MAPS) },
+	},
+	ebpf.HashOfMaps: {
+		Version: "4.12",
+		Fn:      func() error { return probeNestedMap(sys.BPF_MAP_TYPE_HASH_OF_MAPS) },
+	},
+	ebpf.DevMap:   {Version: "4.14"},
+	ebpf.SockMap:  {Version: "4.14"},
+	ebpf.CPUMap:   {Version: "4.15"},
+	ebpf.XSKMap:   {Version: "4.18"},
+	ebpf.SockHash: {Version: "4.18"},
+	ebpf.CGroupStorage: {
+		Version: "4.19",
+		Fn:      func() error { return probeCgroupStorageMap(sys.BPF_MAP_TYPE_CGROUP_STORAGE) },
+	},
+	ebpf.ReusePortSockArray: {Version: "4.19"},
+	ebpf.PerCPUCGroupStorage: {
+		Version: "4.20",
+		Fn:      func() error { return probeCgroupStorageMap(sys.BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE) },
+	},
+	ebpf.Queue: {
+		Version: "4.20",
+		Fn: func() error {
+			return createMap(&sys.MapCreateAttr{
+				MapType:    sys.BPF_MAP_TYPE_QUEUE,
+				KeySize:    0,
+				ValueSize:  4,
+				MaxEntries: 1,
+			})
+		},
+	},
+	ebpf.Stack: {
+		Version: "4.20",
+		Fn: func() error {
+			return createMap(&sys.MapCreateAttr{
+				MapType:    sys.BPF_MAP_TYPE_STACK,
+				KeySize:    0,
+				ValueSize:  4,
+				MaxEntries: 1,
+			})
+		},
+	},
+	ebpf.SkStorage: {
+		Version: "5.2",
+		Fn:      func() error { return probeStorageMap(sys.BPF_MAP_TYPE_SK_STORAGE) },
+	},
+	ebpf.DevMapHash: {Version: "5.4"},
+	ebpf.StructOpsMap: {
+		Version: "5.6",
+		Fn: func() error {
+			// StructOps requires setting a vmlinux type id, but id 1 will always
+			// resolve to some type of integer. This will cause ENOTSUPP.
+			err := probeMap(&sys.MapCreateAttr{
+				MapType:               sys.BPF_MAP_TYPE_STRUCT_OPS,
+				BtfVmlinuxValueTypeId: 1,
+			})
+			if errors.Is(err, sys.ENOTSUPP) {
+				// ENOTSUPP means the map type is at least known to the kernel.
+				return nil
+			}
+			return err
+		},
+	},
+	ebpf.RingBuf: {
+		Version: "5.8",
+		Fn: func() error {
+			// keySize and valueSize need to be 0
+			// maxEntries needs to be power of 2 and PAGE_ALIGNED
+			return createMap(&sys.MapCreateAttr{
+				MapType:    sys.BPF_MAP_TYPE_RINGBUF,
+				KeySize:    0,
+				ValueSize:  0,
+				MaxEntries: uint32(os.Getpagesize()),
+			})
+		},
+	},
+	ebpf.InodeStorage: {
+		Version: "5.10",
+		Fn:      func() error { return probeStorageMap(sys.BPF_MAP_TYPE_INODE_STORAGE) },
+	},
+	ebpf.TaskStorage: {
+		Version: "5.11",
+		Fn:      func() error { return probeStorageMap(sys.BPF_MAP_TYPE_TASK_STORAGE) },
+	},
 }
 
-func isStorageMap(mt ebpf.MapType) bool {
-	switch mt {
-	case ebpf.SkStorage, ebpf.InodeStorage, ebpf.TaskStorage:
-		return true
+func init() {
+	for mt, ft := range haveMapTypeMatrix {
+		ft.Name = mt.String()
+		if ft.Fn == nil {
+			// Avoid referring to the loop variable in the closure.
+			mt := sys.MapType(mt)
+			ft.Fn = func() error { return probeMap(&sys.MapCreateAttr{MapType: mt}) }
+		}
 	}
-	return false
 }
 
 // MapFlags document which flags may be feature probed.
@@ -169,11 +231,11 @@ type MapFlags = sys.MapFlags
 
 // Flags which may be feature probed.
 const (
-	BPF_F_NO_PREALLOC MapFlags = unix.BPF_F_NO_PREALLOC
-	BPF_F_RDONLY_PROG MapFlags = unix.BPF_F_RDONLY_PROG
-	BPF_F_WRONLY_PROG MapFlags = unix.BPF_F_WRONLY_PROG
-	BPF_F_MMAPABLE    MapFlags = unix.BPF_F_MMAPABLE
-	BPF_F_INNER_MAP   MapFlags = unix.BPF_F_INNER_MAP
+	BPF_F_NO_PREALLOC = sys.BPF_F_NO_PREALLOC
+	BPF_F_RDONLY_PROG = sys.BPF_F_RDONLY_PROG
+	BPF_F_WRONLY_PROG = sys.BPF_F_WRONLY_PROG
+	BPF_F_MMAPABLE    = sys.BPF_F_MMAPABLE
+	BPF_F_INNER_MAP   = sys.BPF_F_INNER_MAP
 )
 
 // HaveMapFlag probes the running kernel for the availability of the specified map flag.
@@ -181,63 +243,79 @@ const (
 // Returns an error if flag is not one of the flags declared in this package.
 // See the package documentation for the meaning of the error return value.
 func HaveMapFlag(flag MapFlags) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	return haveMapFlag(flag)
+	return haveMapFlagsMatrix.Result(flag)
 }
 
-func haveMapFlag(flag MapFlags) error {
-	mc.Lock()
-	defer mc.Unlock()
-	err, ok := mc.mapFlags[flag]
-	if ok {
-		return err
+func probeMapFlag(attr *sys.MapCreateAttr) error {
+	// For now, we do not check if the map type is supported because we only support
+	// probing for flags defined on arrays and hashs that are always supported.
+	// In the future, if we allow probing on flags defined on newer types, checking for map type
+	// support will be required.
+	if attr.MapType == sys.BPF_MAP_TYPE_UNSPEC {
+		attr.MapType = sys.BPF_MAP_TYPE_ARRAY
 	}
 
-	attr, err := createMapFlagTypeAttr(flag)
-	if err != nil {
-		return err
-	}
+	attr.KeySize = 4
+	attr.ValueSize = 4
+	attr.MaxEntries = 1
 
 	fd, err := sys.MapCreate(attr)
 	if err == nil {
 		fd.Close()
-	}
-
-	// EINVAL occurs when attempting to create a map with an unknown type or an unknown flag.
-	if errors.Is(err, unix.EINVAL) {
+	} else if errors.Is(err, unix.EINVAL) {
+		// EINVAL occurs when attempting to create a map with an unknown type or an unknown flag.
 		err = ebpf.ErrNotSupported
 	}
 
-	mc.mapFlags[flag] = err
-
 	return err
 }
 
-func createMapFlagTypeAttr(flag MapFlags) (*sys.MapCreateAttr, error) {
-	a := &sys.MapCreateAttr{
-		KeySize:    4,
-		ValueSize:  4,
-		MaxEntries: 1,
-		MapFlags:   flag,
-	}
-
-	// For now, we do not check if the map type is supported because we only support
-	// probing for flags defined on arrays and hashs that are always supported.
-	// In the future, if we allow probing on flags defined on newer types, checking for map type
-	// support will be required.
+var haveMapFlagsMatrix = internal.FeatureMatrix[MapFlags]{
+	BPF_F_NO_PREALLOC: {
+		Version: "4.6",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapType:  sys.BPF_MAP_TYPE_HASH,
+				MapFlags: BPF_F_NO_PREALLOC,
+			})
+		},
+	},
+	BPF_F_RDONLY_PROG: {
+		Version: "5.2",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_RDONLY_PROG,
+			})
+		},
+	},
+	BPF_F_WRONLY_PROG: {
+		Version: "5.2",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_WRONLY_PROG,
+			})
+		},
+	},
+	BPF_F_MMAPABLE: {
+		Version: "5.5",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_MMAPABLE,
+			})
+		},
+	},
+	BPF_F_INNER_MAP: {
+		Version: "5.10",
+		Fn: func() error {
+			return probeMapFlag(&sys.MapCreateAttr{
+				MapFlags: BPF_F_INNER_MAP,
+			})
+		},
+	},
+}
 
-	switch flag {
-	case unix.BPF_F_MMAPABLE, unix.BPF_F_INNER_MAP, unix.BPF_F_RDONLY_PROG, unix.BPF_F_WRONLY_PROG:
-		a.MapType = sys.MapType(ebpf.Array)
-		return a, nil
-	case unix.BPF_F_NO_PREALLOC:
-		a.MapType = sys.MapType(ebpf.Hash)
-		return a, nil
+func init() {
+	for mf, ft := range haveMapFlagsMatrix {
+		ft.Name = fmt.Sprint(mf)
 	}
-
-	return nil, errors.New("probe not implemented")
 }
diff --git a/features/misc.go b/features/misc.go
index afe7bf7..de07d38 100644
--- a/features/misc.go
+++ b/features/misc.go
@@ -1,165 +1,79 @@
 package features
 
 import (
-	"bytes"
-	"errors"
-	"fmt"
-	"sync"
-
 	"github.com/cilium/ebpf"
 	"github.com/cilium/ebpf/asm"
 	"github.com/cilium/ebpf/internal"
-	"github.com/cilium/ebpf/internal/sys"
-	"github.com/cilium/ebpf/internal/unix"
-)
-
-func init() {
-	miscs.miscTypes = make(map[miscType]error)
-}
-
-var (
-	miscs miscCache
-)
-
-type miscCache struct {
-	sync.Mutex
-	miscTypes map[miscType]error
-}
-
-type miscType uint32
-
-const (
-	// largeInsn support introduced in Linux 5.2
-	// commit c04c0d2b968ac45d6ef020316808ef6c82325a82
-	largeInsn miscType = iota
-	// boundedLoops support introduced in Linux 5.3
-	// commit 2589726d12a1b12eaaa93c7f1ea64287e383c7a5
-	boundedLoops
-	// v2ISA support introduced in Linux 4.14
-	// commit 92b31a9af73b3a3fc801899335d6c47966351830
-	v2ISA
-	// v3ISA support introduced in Linux 5.1
-	// commit 092ed0968bb648cd18e8a0430cd0a8a71727315c
-	v3ISA
-)
-
-const (
-	maxInsns = 4096
 )
 
 // HaveLargeInstructions probes the running kernel if more than 4096 instructions
 // per program are supported.
 //
-// See the package documentation for the meaning of the error return value.
-func HaveLargeInstructions() error {
-	return probeMisc(largeInsn)
-}
-
-// HaveBoundedLoops probes the running kernel if bounded loops are supported.
-//
-// See the package documentation for the meaning of the error return value.
-func HaveBoundedLoops() error {
-	return probeMisc(boundedLoops)
-}
-
-// HaveV2ISA probes the running kernel if instructions of the v2 ISA are supported.
-//
-// See the package documentation for the meaning of the error return value.
-func HaveV2ISA() error {
-	return probeMisc(v2ISA)
-}
-
-// HaveV3ISA probes the running kernel if instructions of the v3 ISA are supported.
+// Upstream commit c04c0d2b968a ("bpf: increase complexity limit and maximum program size").
 //
 // See the package documentation for the meaning of the error return value.
-func HaveV3ISA() error {
-	return probeMisc(v3ISA)
-}
-
-// probeMisc checks the kernel for a given supported misc by creating
-// a specialized program probe and loading it.
-func probeMisc(mt miscType) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	miscs.Lock()
-	defer miscs.Unlock()
-	err, ok := miscs.miscTypes[mt]
-	if ok {
-		return err
-	}
+var HaveLargeInstructions = internal.NewFeatureTest(">4096 instructions", "5.2", func() error {
+	const maxInsns = 4096
 
-	attr, err := createMiscProbeAttr(mt)
-	if err != nil {
-		return fmt.Errorf("couldn't create the attributes for the probe: %w", err)
+	insns := make(asm.Instructions, maxInsns, maxInsns+1)
+	for i := range insns {
+		insns[i] = asm.Mov.Imm(asm.R0, 1)
 	}
+	insns = append(insns, asm.Return())
 
-	fd, err := sys.ProgLoad(attr)
-	if err == nil {
-		fd.Close()
-	}
-
-	switch {
-	// EINVAL occurs when attempting to create a program with an unknown type.
-	// E2BIG occurs when ProgLoadAttr contains non-zero bytes past the end
-	// of the struct known by the running kernel, meaning the kernel is too old
-	// to support the given map type.
-	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
-		err = ebpf.ErrNotSupported
-	}
+	return probeProgram(&ebpf.ProgramSpec{
+		Type:         ebpf.SocketFilter,
+		Instructions: insns,
+	})
+})
 
-	miscs.miscTypes[mt] = err
-
-	return err
-}
-
-func createMiscProbeAttr(mt miscType) (*sys.ProgLoadAttr, error) {
-	var insns asm.Instructions
-	switch mt {
-	case largeInsn:
-		for i := 0; i < maxInsns; i++ {
-			insns = append(insns, asm.Mov.Imm(asm.R0, 1))
-		}
-		insns = append(insns, asm.Return())
-	case boundedLoops:
-		insns = asm.Instructions{
+// HaveBoundedLoops probes the running kernel if bounded loops are supported.
+//
+// Upstream commit 2589726d12a1 ("bpf: introduce bounded loops").
+//
+// See the package documentation for the meaning of the error return value.
+var HaveBoundedLoops = internal.NewFeatureTest("bounded loops", "5.3", func() error {
+	return probeProgram(&ebpf.ProgramSpec{
+		Type: ebpf.SocketFilter,
+		Instructions: asm.Instructions{
 			asm.Mov.Imm(asm.R0, 10),
 			asm.Sub.Imm(asm.R0, 1).WithSymbol("loop"),
 			asm.JNE.Imm(asm.R0, 0, "loop"),
 			asm.Return(),
-		}
-	case v2ISA:
-		insns = asm.Instructions{
+		},
+	})
+})
+
+// HaveV2ISA probes the running kernel if instructions of the v2 ISA are supported.
+//
+// Upstream commit 92b31a9af73b ("bpf: add BPF_J{LT,LE,SLT,SLE} instructions").
+//
+// See the package documentation for the meaning of the error return value.
+var HaveV2ISA = internal.NewFeatureTest("v2 ISA", "4.14", func() error {
+	return probeProgram(&ebpf.ProgramSpec{
+		Type: ebpf.SocketFilter,
+		Instructions: asm.Instructions{
 			asm.Mov.Imm(asm.R0, 0),
 			asm.JLT.Imm(asm.R0, 0, "exit"),
 			asm.Mov.Imm(asm.R0, 1),
 			asm.Return().WithSymbol("exit"),
-		}
-	case v3ISA:
-		insns = asm.Instructions{
+		},
+	})
+})
+
+// HaveV3ISA probes the running kernel if instructions of the v3 ISA are supported.
+//
+// Upstream commit 092ed0968bb6 ("bpf: verifier support JMP32").
+//
+// See the package documentation for the meaning of the error return value.
+var HaveV3ISA = internal.NewFeatureTest("v3 ISA", "5.1", func() error {
+	return probeProgram(&ebpf.ProgramSpec{
+		Type: ebpf.SocketFilter,
+		Instructions: asm.Instructions{
 			asm.Mov.Imm(asm.R0, 0),
 			asm.JLT.Imm32(asm.R0, 0, "exit"),
 			asm.Mov.Imm(asm.R0, 1),
 			asm.Return().WithSymbol("exit"),
-		}
-	default:
-		return nil, fmt.Errorf("misc probe %d not implemented", mt)
-	}
-
-	buf := bytes.NewBuffer(make([]byte, 0, insns.Size()))
-	if err := insns.Marshal(buf, internal.NativeEndian); err != nil {
-		return nil, err
-	}
-
-	bytecode := buf.Bytes()
-	instructions := sys.NewSlicePointer(bytecode)
-
-	return &sys.ProgLoadAttr{
-		ProgType: sys.BPF_PROG_TYPE_SOCKET_FILTER,
-		Insns:    instructions,
-		InsnCnt:  uint32(len(bytecode) / asm.InstructionSize),
-		License:  sys.NewStringPointer("MIT"),
-	}, nil
-}
+		},
+	})
+})
diff --git a/features/prog.go b/features/prog.go
index a3a3a83..0aac5bd 100644
--- a/features/prog.go
+++ b/features/prog.go
@@ -1,11 +1,9 @@
 package features
 
 import (
-	"bytes"
 	"errors"
 	"fmt"
 	"os"
-	"sync"
 
 	"github.com/cilium/ebpf"
 	"github.com/cilium/ebpf/asm"
@@ -14,85 +12,6 @@ import (
 	"github.com/cilium/ebpf/internal/unix"
 )
 
-func init() {
-	pc.types = make(map[ebpf.ProgramType]error)
-	pc.helpers = make(map[ebpf.ProgramType]map[asm.BuiltinFunc]error)
-	allocHelperCache()
-}
-
-func allocHelperCache() {
-	for pt := ebpf.UnspecifiedProgram + 1; pt <= pt.Max(); pt++ {
-		pc.helpers[pt] = make(map[asm.BuiltinFunc]error)
-	}
-}
-
-var (
-	pc progCache
-)
-
-type progCache struct {
-	typeMu sync.Mutex
-	types  map[ebpf.ProgramType]error
-
-	helperMu sync.Mutex
-	helpers  map[ebpf.ProgramType]map[asm.BuiltinFunc]error
-}
-
-func createProgLoadAttr(pt ebpf.ProgramType, helper asm.BuiltinFunc) (*sys.ProgLoadAttr, error) {
-	var expectedAttachType ebpf.AttachType
-	var progFlags uint32
-
-	insns := asm.Instructions{
-		asm.LoadImm(asm.R0, 0, asm.DWord),
-		asm.Return(),
-	}
-
-	if helper != asm.FnUnspec {
-		insns = append(asm.Instructions{helper.Call()}, insns...)
-	}
-
-	buf := bytes.NewBuffer(make([]byte, 0, insns.Size()))
-	if err := insns.Marshal(buf, internal.NativeEndian); err != nil {
-		return nil, err
-	}
-
-	bytecode := buf.Bytes()
-	instructions := sys.NewSlicePointer(bytecode)
-
-	// Some programs have expected attach types which are checked during the
-	// BPF_PROG_LOAD syscall.
-	switch pt {
-	case ebpf.CGroupSockAddr:
-		expectedAttachType = ebpf.AttachCGroupInet4Connect
-	case ebpf.CGroupSockopt:
-		expectedAttachType = ebpf.AttachCGroupGetsockopt
-	case ebpf.SkLookup:
-		expectedAttachType = ebpf.AttachSkLookup
-	case ebpf.Syscall:
-		progFlags = unix.BPF_F_SLEEPABLE
-	default:
-		expectedAttachType = ebpf.AttachNone
-	}
-
-	// Kernels before 5.0 (6c4fc209fcf9 "bpf: remove useless version check for prog load")
-	// require the version field to be set to the value of the KERNEL_VERSION
-	// macro for kprobe-type programs.
-	v, err := internal.KernelVersion()
-	if err != nil {
-		return nil, fmt.Errorf("detecting kernel version: %w", err)
-	}
-
-	return &sys.ProgLoadAttr{
-		ProgType:           sys.ProgType(pt),
-		Insns:              instructions,
-		InsnCnt:            uint32(len(bytecode) / asm.InstructionSize),
-		ProgFlags:          progFlags,
-		ExpectedAttachType: sys.AttachType(expectedAttachType),
-		License:            sys.NewStringPointer("GPL"),
-		KernVersion:        v.Kernel(),
-	}, nil
-}
-
 // HaveProgType probes the running kernel for the availability of the specified program type.
 //
 // Deprecated: use HaveProgramType() instead.
@@ -102,49 +21,21 @@ var HaveProgType = HaveProgramType
 //
 // See the package documentation for the meaning of the error return value.
 func HaveProgramType(pt ebpf.ProgramType) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	if err := validateProgramType(pt); err != nil {
-		return err
-	}
-
-	return haveProgramType(pt)
-
-}
-
-func validateProgramType(pt ebpf.ProgramType) error {
-	if pt > pt.Max() {
-		return os.ErrInvalid
-	}
-
-	if progLoadProbeNotImplemented(pt) {
-		// A probe for a these prog types has BTF requirements we currently cannot meet
-		// Once we figure out how to add a working probe in this package, we can remove
-		// this check
-		return fmt.Errorf("a probe for ProgType %s isn't implemented", pt.String())
-	}
-
-	return nil
+	return haveProgramTypeMatrix.Result(pt)
 }
 
-func haveProgramType(pt ebpf.ProgramType) error {
-	pc.typeMu.Lock()
-	defer pc.typeMu.Unlock()
-	if err, ok := pc.types[pt]; ok {
-		return err
-	}
-
-	attr, err := createProgLoadAttr(pt, asm.FnUnspec)
-	if err != nil {
-		return fmt.Errorf("couldn't create the program load attribute: %w", err)
+func probeProgram(spec *ebpf.ProgramSpec) error {
+	if spec.Instructions == nil {
+		spec.Instructions = asm.Instructions{
+			asm.LoadImm(asm.R0, 0, asm.DWord),
+			asm.Return(),
+		}
 	}
-
-	fd, err := sys.ProgLoad(attr)
-	if fd != nil {
-		fd.Close()
+	prog, err := ebpf.NewProgramWithOptions(spec, ebpf.ProgramOptions{
+		LogDisabled: true,
+	})
+	if err == nil {
+		prog.Close()
 	}
 
 	switch {
@@ -154,19 +45,114 @@ func haveProgramType(pt ebpf.ProgramType) error {
 	// to support the given prog type.
 	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
 		err = ebpf.ErrNotSupported
+	}
+
+	return err
+}
+
+var haveProgramTypeMatrix = internal.FeatureMatrix[ebpf.ProgramType]{
+	ebpf.SocketFilter:  {Version: "3.19"},
+	ebpf.Kprobe:        {Version: "4.1"},
+	ebpf.SchedCLS:      {Version: "4.1"},
+	ebpf.SchedACT:      {Version: "4.1"},
+	ebpf.TracePoint:    {Version: "4.7"},
+	ebpf.XDP:           {Version: "4.8"},
+	ebpf.PerfEvent:     {Version: "4.9"},
+	ebpf.CGroupSKB:     {Version: "4.10"},
+	ebpf.CGroupSock:    {Version: "4.10"},
+	ebpf.LWTIn:         {Version: "4.10"},
+	ebpf.LWTOut:        {Version: "4.10"},
+	ebpf.LWTXmit:       {Version: "4.10"},
+	ebpf.SockOps:       {Version: "4.13"},
+	ebpf.SkSKB:         {Version: "4.14"},
+	ebpf.CGroupDevice:  {Version: "4.15"},
+	ebpf.SkMsg:         {Version: "4.17"},
+	ebpf.RawTracepoint: {Version: "4.17"},
+	ebpf.CGroupSockAddr: {
+		Version: "4.17",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:       ebpf.CGroupSockAddr,
+				AttachType: ebpf.AttachCGroupInet4Connect,
+			})
+		},
+	},
+	ebpf.LWTSeg6Local:          {Version: "4.18"},
+	ebpf.LircMode2:             {Version: "4.18"},
+	ebpf.SkReuseport:           {Version: "4.19"},
+	ebpf.FlowDissector:         {Version: "4.20"},
+	ebpf.CGroupSysctl:          {Version: "5.2"},
+	ebpf.RawTracepointWritable: {Version: "5.2"},
+	ebpf.CGroupSockopt: {
+		Version: "5.3",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:       ebpf.CGroupSockopt,
+				AttachType: ebpf.AttachCGroupGetsockopt,
+			})
+		},
+	},
+	// ebpf.Tracing:               {Version: "5.5"},
+	ebpf.StructOps: {
+		Version: "5.6",
+		Fn: func() error {
+			err := probeProgram(&ebpf.ProgramSpec{
+				Type:    ebpf.StructOps,
+				License: "GPL",
+			})
+			if errors.Is(err, sys.ENOTSUPP) {
+				// ENOTSUPP means the program type is at least known to the kernel.
+				return nil
+			}
+			return err
+		},
+	},
+	// ebpf.Extension:             {Version: "5.6"},
+	// ebpf.LSM:                   {Version: "5.7"},
+	ebpf.SkLookup: {
+		Version: "5.9",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:       ebpf.SkLookup,
+				AttachType: ebpf.AttachSkLookup,
+			})
+		},
+	},
+	ebpf.Syscall: {
+		Version: "5.14",
+		Fn: func() error {
+			return probeProgram(&ebpf.ProgramSpec{
+				Type:  ebpf.Syscall,
+				Flags: unix.BPF_F_SLEEPABLE,
+			})
+		},
+	},
+}
 
-	// ENOTSUPP means the program type is at least known to the kernel.
-	case errors.Is(err, sys.ENOTSUPP):
-		if pt == ebpf.StructOps {
-			err = nil
+func init() {
+	for key, ft := range haveProgramTypeMatrix {
+		ft.Name = key.String()
+		if ft.Fn == nil {
+			key := key // avoid the dreaded loop variable problem
+			ft.Fn = func() error { return probeProgram(&ebpf.ProgramSpec{Type: key}) }
 		}
 	}
+}
 
-	pc.types[pt] = err
-
-	return err
+type helperKey struct {
+	typ    ebpf.ProgramType
+	helper asm.BuiltinFunc
 }
 
+var helperCache = internal.NewFeatureCache(func(key helperKey) *internal.FeatureTest {
+	return &internal.FeatureTest{
+		Name: fmt.Sprintf("%s for program type %s", key.helper, key.typ),
+		Fn: func() error {
+			return haveProgramHelper(key.typ, key.helper)
+		},
+	}
+})
+
 // HaveProgramHelper probes the running kernel for the availability of the specified helper
 // function to a specified program type.
 // Return values have the following semantics:
@@ -180,46 +166,45 @@ func haveProgramType(pt ebpf.ProgramType) error {
 // Only `nil` and `ebpf.ErrNotSupported` are conclusive.
 //
 // Probe results are cached and persist throughout any process capability changes.
-func HaveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) (err error) {
-	defer func() {
-		// This closure modifies a named return variable.
-		err = wrapProbeErrors(err)
-	}()
-
-	if err := validateProgramType(pt); err != nil {
-		return err
-	}
-
-	if err := validateProgramHelper(helper); err != nil {
-		return err
-	}
-
-	return haveProgramHelper(pt, helper)
-}
-
-func validateProgramHelper(helper asm.BuiltinFunc) error {
+func HaveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) error {
 	if helper > helper.Max() {
 		return os.ErrInvalid
 	}
 
-	return nil
+	return helperCache.Result(helperKey{pt, helper})
 }
 
 func haveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) error {
-	pc.helperMu.Lock()
-	defer pc.helperMu.Unlock()
-	if err, ok := pc.helpers[pt][helper]; ok {
+	if err := HaveProgramType(pt); err != nil {
 		return err
 	}
 
-	attr, err := createProgLoadAttr(pt, helper)
-	if err != nil {
-		return fmt.Errorf("couldn't create the program load attribute: %w", err)
+	spec := &ebpf.ProgramSpec{
+		Type: pt,
+		Instructions: asm.Instructions{
+			helper.Call(),
+			asm.LoadImm(asm.R0, 0, asm.DWord),
+			asm.Return(),
+		},
+		License: "GPL",
+	}
+
+	switch pt {
+	case ebpf.CGroupSockAddr:
+		spec.AttachType = ebpf.AttachCGroupInet4Connect
+	case ebpf.CGroupSockopt:
+		spec.AttachType = ebpf.AttachCGroupGetsockopt
+	case ebpf.SkLookup:
+		spec.AttachType = ebpf.AttachSkLookup
+	case ebpf.Syscall:
+		spec.Flags = unix.BPF_F_SLEEPABLE
 	}
 
-	fd, err := sys.ProgLoad(attr)
-	if fd != nil {
-		fd.Close()
+	prog, err := ebpf.NewProgramWithOptions(spec, ebpf.ProgramOptions{
+		LogDisabled: true,
+	})
+	if err == nil {
+		prog.Close()
 	}
 
 	switch {
@@ -232,23 +217,10 @@ func haveProgramHelper(pt ebpf.ProgramType, helper asm.BuiltinFunc) error {
 		err = nil
 
 	// EINVAL occurs when attempting to create a program with an unknown helper.
-	// E2BIG occurs when BPFProgLoadAttr contains non-zero bytes past the end
-	// of the struct known by the running kernel, meaning the kernel is too old
-	// to support the given prog type.
-	case errors.Is(err, unix.EINVAL), errors.Is(err, unix.E2BIG):
+	case errors.Is(err, unix.EINVAL):
 		// TODO: possibly we need to check verifier output here to be sure
 		err = ebpf.ErrNotSupported
 	}
 
-	pc.helpers[pt][helper] = err
-
 	return err
 }
-
-func progLoadProbeNotImplemented(pt ebpf.ProgramType) bool {
-	switch pt {
-	case ebpf.Tracing, ebpf.Extension, ebpf.LSM:
-		return true
-	}
-	return false
-}
diff --git a/internal/feature.go b/internal/feature.go
index bf25d78..b1f6507 100644
--- a/internal/feature.go
+++ b/internal/feature.go
@@ -31,10 +31,20 @@ func (ufe *UnsupportedFeatureError) Is(target error) bool {
 	return target == ErrNotSupported
 }
 
-type featureTest struct {
-	sync.RWMutex
-	successful bool
-	result     error
+// FeatureTest caches the result of a [FeatureTestFn].
+//
+// Fields should not be modified after creation.
+type FeatureTest struct {
+	// The name of the feature being detected.
+	Name string
+	// Version in in the form Major.Minor[.Patch].
+	Version string
+	// The feature test itself.
+	Fn FeatureTestFn
+
+	mu     sync.RWMutex
+	done   bool
+	result error
 }
 
 // FeatureTestFn is used to determine whether the kernel supports
@@ -47,54 +57,128 @@ type featureTest struct {
 //	err != nil: the test couldn't be executed
 type FeatureTestFn func() error
 
-// FeatureTest wraps a function so that it is run at most once.
+// NewFeatureTest is a convenient way to create a single [FeatureTest].
+func NewFeatureTest(name, version string, fn FeatureTestFn) func() error {
+	ft := &FeatureTest{
+		Name:    name,
+		Version: version,
+		Fn:      fn,
+	}
+
+	return ft.execute
+}
+
+// execute the feature test.
 //
-// name should identify the tested feature, while version must be in the
-// form Major.Minor[.Patch].
+// The result is cached if the test is conclusive.
 //
-// Returns an error wrapping ErrNotSupported if the feature is not supported.
-func FeatureTest(name, version string, fn FeatureTestFn) func() error {
-	ft := new(featureTest)
-	return func() error {
-		ft.RLock()
-		if ft.successful {
-			defer ft.RUnlock()
-			return ft.result
-		}
-		ft.RUnlock()
-		ft.Lock()
-		defer ft.Unlock()
-		// check one more time on the off
-		// chance that two go routines
-		// were able to call into the write
-		// lock
-		if ft.successful {
-			return ft.result
-		}
-		err := fn()
-		switch {
-		case errors.Is(err, ErrNotSupported):
-			v, err := NewVersion(version)
-			if err != nil {
-				return err
-			}
+// See [FeatureTestFn] for the meaning of the returned error.
+func (ft *FeatureTest) execute() error {
+	ft.mu.RLock()
+	result, done := ft.result, ft.done
+	ft.mu.RUnlock()
 
-			ft.result = &UnsupportedFeatureError{
-				MinimumVersion: v,
-				Name:           name,
-			}
-			fallthrough
+	if done {
+		return result
+	}
+
+	ft.mu.Lock()
+	defer ft.mu.Unlock()
+
+	// The test may have been executed by another caller while we were
+	// waiting to acquire ft.mu.
+	if ft.done {
+		return ft.result
+	}
+
+	err := ft.Fn()
+	if err == nil {
+		ft.done = true
+		return nil
+	}
 
-		case err == nil:
-			ft.successful = true
+	if errors.Is(err, ErrNotSupported) {
+		var v Version
+		if ft.Version != "" {
+			v, err = NewVersion(ft.Version)
+			if err != nil {
+				return fmt.Errorf("feature %s: %w", ft.Name, err)
+			}
+		}
 
-		default:
-			// We couldn't execute the feature test to a point
-			// where it could make a determination.
-			// Don't cache the result, just return it.
-			return fmt.Errorf("detect support for %s: %w", name, err)
+		ft.done = true
+		ft.result = &UnsupportedFeatureError{
+			MinimumVersion: v,
+			Name:           ft.Name,
 		}
 
 		return ft.result
 	}
+
+	// We couldn't execute the feature test to a point
+	// where it could make a determination.
+	// Don't cache the result, just return it.
+	return fmt.Errorf("detect support for %s: %w", ft.Name, err)
+}
+
+// FeatureMatrix groups multiple related feature tests into a map.
+//
+// Useful when there is a small number of discrete features which are known
+// at compile time.
+//
+// It must not be modified concurrently with calling [FeatureMatrix.Result].
+type FeatureMatrix[K comparable] map[K]*FeatureTest
+
+// Result returns the outcome of the feature test for the given key.
+//
+// It's safe to call this function concurrently.
+func (fm FeatureMatrix[K]) Result(key K) error {
+	ft, ok := fm[key]
+	if !ok {
+		return fmt.Errorf("no feature probe for %v", key)
+	}
+
+	return ft.execute()
+}
+
+// FeatureCache caches a potentially unlimited number of feature probes.
+//
+// Useful when there is a high cardinality for a feature test.
+type FeatureCache[K comparable] struct {
+	mu       sync.RWMutex
+	newTest  func(K) *FeatureTest
+	features map[K]*FeatureTest
+}
+
+func NewFeatureCache[K comparable](newTest func(K) *FeatureTest) *FeatureCache[K] {
+	return &FeatureCache[K]{
+		newTest:  newTest,
+		features: make(map[K]*FeatureTest),
+	}
+}
+
+func (fc *FeatureCache[K]) Result(key K) error {
+	// NB: Executing the feature test happens without fc.mu taken.
+	return fc.retrieve(key).execute()
+}
+
+func (fc *FeatureCache[K]) retrieve(key K) *FeatureTest {
+	fc.mu.RLock()
+	ft := fc.features[key]
+	fc.mu.RUnlock()
+
+	if ft != nil {
+		return ft
+	}
+
+	fc.mu.Lock()
+	defer fc.mu.Unlock()
+
+	if ft := fc.features[key]; ft != nil {
+		return ft
+	}
+
+	ft = fc.newTest(key)
+	fc.features[key] = ft
+	return ft
 }
diff --git a/internal/sys/mapflags_string.go b/internal/sys/mapflags_string.go
new file mode 100644
index 0000000..c80744a
--- /dev/null
+++ b/internal/sys/mapflags_string.go
@@ -0,0 +1,49 @@
+// Code generated by "stringer -type MapFlags"; DO NOT EDIT.
+
+package sys
+
+import "strconv"
+
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[BPF_F_NO_PREALLOC-1]
+	_ = x[BPF_F_NO_COMMON_LRU-2]
+	_ = x[BPF_F_NUMA_NODE-4]
+	_ = x[BPF_F_RDONLY-8]
+	_ = x[BPF_F_WRONLY-16]
+	_ = x[BPF_F_STACK_BUILD_ID-32]
+	_ = x[BPF_F_ZERO_SEED-64]
+	_ = x[BPF_F_RDONLY_PROG-128]
+	_ = x[BPF_F_WRONLY_PROG-256]
+	_ = x[BPF_F_CLONE-512]
+	_ = x[BPF_F_MMAPABLE-1024]
+	_ = x[BPF_F_PRESERVE_ELEMS-2048]
+	_ = x[BPF_F_INNER_MAP-4096]
+}
+
+const _MapFlags_name = "BPF_F_NO_PREALLOCBPF_F_NO_COMMON_LRUBPF_F_NUMA_NODEBPF_F_RDONLYBPF_F_WRONLYBPF_F_STACK_BUILD_IDBPF_F_ZERO_SEEDBPF_F_RDONLY_PROGBPF_F_WRONLY_PROGBPF_F_CLONEBPF_F_MMAPABLEBPF_F_PRESERVE_ELEMSBPF_F_INNER_MAP"
+
+var _MapFlags_map = map[MapFlags]string{
+	1:    _MapFlags_name[0:17],
+	2:    _MapFlags_name[17:36],
+	4:    _MapFlags_name[36:51],
+	8:    _MapFlags_name[51:63],
+	16:   _MapFlags_name[63:75],
+	32:   _MapFlags_name[75:95],
+	64:   _MapFlags_name[95:110],
+	128:  _MapFlags_name[110:127],
+	256:  _MapFlags_name[127:144],
+	512:  _MapFlags_name[144:155],
+	1024: _MapFlags_name[155:169],
+	2048: _MapFlags_name[169:189],
+	4096: _MapFlags_name[189:204],
+}
+
+func (i MapFlags) String() string {
+	if str, ok := _MapFlags_map[i]; ok {
+		return str
+	}
+	return "MapFlags(" + strconv.FormatInt(int64(i), 10) + ")"
+}
diff --git a/internal/sys/syscall.go b/internal/sys/syscall.go
index 3205ede..3ffcd7a 100644
--- a/internal/sys/syscall.go
+++ b/internal/sys/syscall.go
@@ -120,6 +120,24 @@ type BTFID uint32
 // MapFlags control map behaviour.
 type MapFlags uint32
 
+//go:generate stringer -type MapFlags
+
+const (
+	BPF_F_NO_PREALLOC MapFlags = 1 << iota
+	BPF_F_NO_COMMON_LRU
+	BPF_F_NUMA_NODE
+	BPF_F_RDONLY
+	BPF_F_WRONLY
+	BPF_F_STACK_BUILD_ID
+	BPF_F_ZERO_SEED
+	BPF_F_RDONLY_PROG
+	BPF_F_WRONLY_PROG
+	BPF_F_CLONE
+	BPF_F_MMAPABLE
+	BPF_F_PRESERVE_ELEMS
+	BPF_F_INNER_MAP
+)
+
 // wrappedErrno wraps syscall.Errno to prevent direct comparisons with
 // syscall.E* or unix.E* constants.
 //
diff --git a/link/kprobe_multi.go b/link/kprobe_multi.go
index f9d1f0e..151f47d 100644
--- a/link/kprobe_multi.go
+++ b/link/kprobe_multi.go
@@ -138,7 +138,7 @@ func (kml *kprobeMultiLink) Unpin() error {
 	return fmt.Errorf("unpin kprobe_multi: %w", ErrNotSupported)
 }
 
-var haveBPFLinkKprobeMulti = internal.FeatureTest("bpf_link_kprobe_multi", "5.18", func() error {
+var haveBPFLinkKprobeMulti = internal.NewFeatureTest("bpf_link_kprobe_multi", "5.18", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Name: "probe_kpm_link",
 		Type: ebpf.Kprobe,
diff --git a/link/perf_event.go b/link/perf_event.go
index 602f45b..ab2f049 100644
--- a/link/perf_event.go
+++ b/link/perf_event.go
@@ -380,7 +380,7 @@ func readUint64FromFileOnce(format string, path ...string) (uint64, error) {
 //
 // https://elixir.bootlin.com/linux/v5.16.8/source/kernel/bpf/syscall.c#L4307
 // https://github.com/torvalds/linux/commit/b89fbfbb854c9afc3047e8273cc3a694650b802e
-var haveBPFLinkPerfEvent = internal.FeatureTest("bpf_link_perf_event", "5.15", func() error {
+var haveBPFLinkPerfEvent = internal.NewFeatureTest("bpf_link_perf_event", "5.15", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Name: "probe_bpf_perf_link",
 		Type: ebpf.Kprobe,
diff --git a/link/syscalls.go b/link/syscalls.go
index 035d875..a9fed90 100644
--- a/link/syscalls.go
+++ b/link/syscalls.go
@@ -26,7 +26,7 @@ const (
 	KprobeMultiType   = sys.BPF_LINK_TYPE_KPROBE_MULTI
 )
 
-var haveProgAttach = internal.FeatureTest("BPF_PROG_ATTACH", "4.10", func() error {
+var haveProgAttach = internal.NewFeatureTest("BPF_PROG_ATTACH", "4.10", func() error {
 	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
 		Type:    ebpf.CGroupSKB,
 		License: "MIT",
@@ -46,7 +46,7 @@ var haveProgAttach = internal.FeatureTest("BPF_PROG_ATTACH", "4.10", func() erro
 	return nil
 })
 
-var haveProgAttachReplace = internal.FeatureTest("BPF_PROG_ATTACH atomic replacement", "5.5", func() error {
+var haveProgAttachReplace = internal.NewFeatureTest("BPF_PROG_ATTACH atomic replacement", "5.5", func() error {
 	if err := haveProgAttach(); err != nil {
 		return err
 	}
@@ -86,7 +86,7 @@ var haveProgAttachReplace = internal.FeatureTest("BPF_PROG_ATTACH atomic replace
 	return err
 })
 
-var haveBPFLink = internal.FeatureTest("bpf_link", "5.7", func() error {
+var haveBPFLink = internal.NewFeatureTest("bpf_link", "5.7", func() error {
 	attr := sys.LinkCreateAttr{
 		// This is a hopefully invalid file descriptor, which triggers EBADF.
 		TargetFd:   ^uint32(0),
diff --git a/link/uprobe.go b/link/uprobe.go
index b4ce55f..aa1ad9b 100644
--- a/link/uprobe.go
+++ b/link/uprobe.go
@@ -18,7 +18,7 @@ var (
 	uprobeRefCtrOffsetPMUPath = "/sys/bus/event_source/devices/uprobe/format/ref_ctr_offset"
 	// elixir.bootlin.com/linux/v5.15-rc7/source/kernel/events/core.c#L9799
 	uprobeRefCtrOffsetShift = 32
-	haveRefCtrOffsetPMU     = internal.FeatureTest("RefCtrOffsetPMU", "4.20", func() error {
+	haveRefCtrOffsetPMU     = internal.NewFeatureTest("RefCtrOffsetPMU", "4.20", func() error {
 		_, err := os.Stat(uprobeRefCtrOffsetPMUPath)
 		if err != nil {
 			return internal.ErrNotSupported
diff --git a/prog.go b/prog.go
index e0e3a7a..b5a9dce 100644
--- a/prog.go
+++ b/prog.go
@@ -593,7 +593,7 @@ func (p *Program) Benchmark(in []byte, repeat int, reset func()) (uint32, time.D
 	return ret, total, nil
 }
 
-var haveProgTestRun = internal.FeatureTest("BPF_PROG_TEST_RUN", "4.12", func() error {
+var haveProgTestRun = internal.NewFeatureTest("BPF_PROG_TEST_RUN", "4.12", func() error {
 	prog, err := NewProgram(&ProgramSpec{
 		// SocketFilter does not require privileges on newer kernels.
 		Type: SocketFilter,
diff --git a/syscalls.go b/syscalls.go
index c100142..1ab1336 100644
--- a/syscalls.go
+++ b/syscalls.go
@@ -47,7 +47,7 @@ func progLoad(insns asm.Instructions, typ ProgramType, license string) (*sys.FD,
 	})
 }
 
-var haveNestedMaps = internal.FeatureTest("nested maps", "4.12", func() error {
+var haveNestedMaps = internal.NewFeatureTest("nested maps", "4.12", func() error {
 	_, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(ArrayOfMaps),
 		KeySize:    4,
@@ -65,7 +65,7 @@ var haveNestedMaps = internal.FeatureTest("nested maps", "4.12", func() error {
 	return err
 })
 
-var haveMapMutabilityModifiers = internal.FeatureTest("read- and write-only maps", "5.2", func() error {
+var haveMapMutabilityModifiers = internal.NewFeatureTest("read- and write-only maps", "5.2", func() error {
 	// This checks BPF_F_RDONLY_PROG and BPF_F_WRONLY_PROG. Since
 	// BPF_MAP_FREEZE appeared in 5.2 as well we don't do a separate check.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
@@ -82,7 +82,7 @@ var haveMapMutabilityModifiers = internal.FeatureTest("read- and write-only maps
 	return nil
 })
 
-var haveMmapableMaps = internal.FeatureTest("mmapable maps", "5.5", func() error {
+var haveMmapableMaps = internal.NewFeatureTest("mmapable maps", "5.5", func() error {
 	// This checks BPF_F_MMAPABLE, which appeared in 5.5 for array maps.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
@@ -98,7 +98,7 @@ var haveMmapableMaps = internal.FeatureTest("mmapable maps", "5.5", func() error
 	return nil
 })
 
-var haveInnerMaps = internal.FeatureTest("inner maps", "5.10", func() error {
+var haveInnerMaps = internal.NewFeatureTest("inner maps", "5.10", func() error {
 	// This checks BPF_F_INNER_MAP, which appeared in 5.10.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
@@ -114,7 +114,7 @@ var haveInnerMaps = internal.FeatureTest("inner maps", "5.10", func() error {
 	return nil
 })
 
-var haveNoPreallocMaps = internal.FeatureTest("prealloc maps", "4.6", func() error {
+var haveNoPreallocMaps = internal.NewFeatureTest("prealloc maps", "4.6", func() error {
 	// This checks BPF_F_NO_PREALLOC, which appeared in 4.6.
 	m, err := sys.MapCreate(&sys.MapCreateAttr{
 		MapType:    sys.MapType(Hash),
@@ -154,7 +154,7 @@ func wrapMapError(err error) error {
 	return err
 }
 
-var haveObjName = internal.FeatureTest("object names", "4.15", func() error {
+var haveObjName = internal.NewFeatureTest("object names", "4.15", func() error {
 	attr := sys.MapCreateAttr{
 		MapType:    sys.MapType(Array),
 		KeySize:    4,
@@ -172,7 +172,7 @@ var haveObjName = internal.FeatureTest("object names", "4.15", func() error {
 	return nil
 })
 
-var objNameAllowsDot = internal.FeatureTest("dot in object names", "5.2", func() error {
+var objNameAllowsDot = internal.NewFeatureTest("dot in object names", "5.2", func() error {
 	if err := haveObjName(); err != nil {
 		return err
 	}
@@ -194,7 +194,7 @@ var objNameAllowsDot = internal.FeatureTest("dot in object names", "5.2", func()
 	return nil
 })
 
-var haveBatchAPI = internal.FeatureTest("map batch api", "5.6", func() error {
+var haveBatchAPI = internal.NewFeatureTest("map batch api", "5.6", func() error {
 	var maxEntries uint32 = 2
 	attr := sys.MapCreateAttr{
 		MapType:    sys.MapType(Hash),
@@ -226,7 +226,7 @@ var haveBatchAPI = internal.FeatureTest("map batch api", "5.6", func() error {
 	return nil
 })
 
-var haveProbeReadKernel = internal.FeatureTest("bpf_probe_read_kernel", "5.5", func() error {
+var haveProbeReadKernel = internal.NewFeatureTest("bpf_probe_read_kernel", "5.5", func() error {
 	insns := asm.Instructions{
 		asm.Mov.Reg(asm.R1, asm.R10),
 		asm.Add.Imm(asm.R1, -8),
@@ -244,7 +244,7 @@ var haveProbeReadKernel = internal.FeatureTest("bpf_probe_read_kernel", "5.5", f
 	return nil
 })
 
-var haveBPFToBPFCalls = internal.FeatureTest("bpf2bpf calls", "4.16", func() error {
+var haveBPFToBPFCalls = internal.NewFeatureTest("bpf2bpf calls", "4.16", func() error {
 	insns := asm.Instructions{
 		asm.Call.Label("prog2").WithSymbol("prog1"),
 		asm.Return(),
diff --git a/types.go b/types.go
index 946c48b..35927e2 100644
--- a/types.go
+++ b/types.go
@@ -11,11 +11,6 @@ import (
 // that will be initialized in the kernel.
 type MapType uint32
 
-// Max returns the latest supported MapType.
-func (MapType) Max() MapType {
-	return maxMapType - 1
-}
-
 // All the various map types that can be created
 const (
 	UnspecifiedMap MapType = iota
@@ -100,8 +95,6 @@ const (
 	InodeStorage
 	// TaskStorage - Specialized local storage map for task_struct.
 	TaskStorage
-	// maxMapType - Bound enum of MapTypes, has to be last in enum.
-	maxMapType
 )
 
 // hasPerCPUValue returns true if the Map stores a value per CPU.
@@ -124,11 +117,6 @@ func (mt MapType) canStoreProgram() bool {
 // ProgramType of the eBPF program
 type ProgramType uint32
 
-// Max return the latest supported ProgramType.
-func (ProgramType) Max() ProgramType {
-	return maxProgramType - 1
-}
-
 // eBPF program types
 const (
 	UnspecifiedProgram ProgramType = iota
@@ -163,7 +151,6 @@ const (
 	LSM
 	SkLookup
 	Syscall
-	maxProgramType
 )
 
 // AttachType of the eBPF program, needed to differentiate allowed context accesses in
diff --git a/types_string.go b/types_string.go
index e80b948..5679f22 100644
--- a/types_string.go
+++ b/types_string.go
@@ -38,12 +38,11 @@ func _() {
 	_ = x[RingBuf-27]
 	_ = x[InodeStorage-28]
 	_ = x[TaskStorage-29]
-	_ = x[maxMapType-30]
 }
 
-const _MapType_name = "UnspecifiedMapHashArrayProgramArrayPerfEventArrayPerCPUHashPerCPUArrayStackTraceCGroupArrayLRUHashLRUCPUHashLPMTrieArrayOfMapsHashOfMapsDevMapSockMapCPUMapXSKMapSockHashCGroupStorageReusePortSockArrayPerCPUCGroupStorageQueueStackSkStorageDevMapHashStructOpsMapRingBufInodeStorageTaskStoragemaxMapType"
+const _MapType_name = "UnspecifiedMapHashArrayProgramArrayPerfEventArrayPerCPUHashPerCPUArrayStackTraceCGroupArrayLRUHashLRUCPUHashLPMTrieArrayOfMapsHashOfMapsDevMapSockMapCPUMapXSKMapSockHashCGroupStorageReusePortSockArrayPerCPUCGroupStorageQueueStackSkStorageDevMapHashStructOpsMapRingBufInodeStorageTaskStorage"
 
-var _MapType_index = [...]uint16{0, 14, 18, 23, 35, 49, 59, 70, 80, 91, 98, 108, 115, 126, 136, 142, 149, 155, 161, 169, 182, 200, 219, 224, 229, 238, 248, 260, 267, 279, 290, 300}
+var _MapType_index = [...]uint16{0, 14, 18, 23, 35, 49, 59, 70, 80, 91, 98, 108, 115, 126, 136, 142, 149, 155, 161, 169, 182, 200, 219, 224, 229, 238, 248, 260, 267, 279, 290}
 
 func (i MapType) String() string {
 	if i >= MapType(len(_MapType_index)-1) {
@@ -87,12 +86,11 @@ func _() {
 	_ = x[LSM-29]
 	_ = x[SkLookup-30]
 	_ = x[Syscall-31]
-	_ = x[maxProgramType-32]
 }
 
-const _ProgramType_name = "UnspecifiedProgramSocketFilterKprobeSchedCLSSchedACTTracePointXDPPerfEventCGroupSKBCGroupSockLWTInLWTOutLWTXmitSockOpsSkSKBCGroupDeviceSkMsgRawTracepointCGroupSockAddrLWTSeg6LocalLircMode2SkReuseportFlowDissectorCGroupSysctlRawTracepointWritableCGroupSockoptTracingStructOpsExtensionLSMSkLookupSyscallmaxProgramType"
+const _ProgramType_name = "UnspecifiedProgramSocketFilterKprobeSchedCLSSchedACTTracePointXDPPerfEventCGroupSKBCGroupSockLWTInLWTOutLWTXmitSockOpsSkSKBCGroupDeviceSkMsgRawTracepointCGroupSockAddrLWTSeg6LocalLircMode2SkReuseportFlowDissectorCGroupSysctlRawTracepointWritableCGroupSockoptTracingStructOpsExtensionLSMSkLookupSyscall"
 
-var _ProgramType_index = [...]uint16{0, 18, 30, 36, 44, 52, 62, 65, 74, 83, 93, 98, 104, 111, 118, 123, 135, 140, 153, 167, 179, 188, 199, 212, 224, 245, 258, 265, 274, 283, 286, 294, 301, 315}
+var _ProgramType_index = [...]uint16{0, 18, 30, 36, 44, 52, 62, 65, 74, 83, 93, 98, 104, 111, 118, 123, 135, 140, 153, 167, 179, 188, 199, 212, 224, 245, 258, 265, 274, 283, 286, 294, 301}
 
 func (i ProgramType) String() string {
 	if i >= ProgramType(len(_ProgramType_index)-1) {


Test patch:
diff --git a/features/map_test.go b/features/map_test.go
index 9b08fc1..f2a0a44 100644
--- a/features/map_test.go
+++ b/features/map_test.go
@@ -2,107 +2,26 @@ package features
 
 import (
 	"errors"
-	"fmt"
 	"math"
-	"os"
 	"testing"
 
 	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal"
 	"github.com/cilium/ebpf/internal/testutils"
 )
 
-var mapTypeMinVersion = map[ebpf.MapType]string{
-	ebpf.Hash:                "3.19",
-	ebpf.Array:               "3.19",
-	ebpf.ProgramArray:        "4.2",
-	ebpf.PerfEventArray:      "4.3",
-	ebpf.PerCPUHash:          "4.6",
-	ebpf.PerCPUArray:         "4.6",
-	ebpf.StackTrace:          "4.6",
-	ebpf.CGroupArray:         "4.8",
-	ebpf.LRUHash:             "4.10",
-	ebpf.LRUCPUHash:          "4.10",
-	ebpf.LPMTrie:             "4.11",
-	ebpf.ArrayOfMaps:         "4.12",
-	ebpf.HashOfMaps:          "4.12",
-	ebpf.DevMap:              "4.14",
-	ebpf.SockMap:             "4.14",
-	ebpf.CPUMap:              "4.15",
-	ebpf.XSKMap:              "4.18",
-	ebpf.SockHash:            "4.18",
-	ebpf.CGroupStorage:       "4.19",
-	ebpf.ReusePortSockArray:  "4.19",
-	ebpf.PerCPUCGroupStorage: "4.20",
-	ebpf.Queue:               "4.20",
-	ebpf.Stack:               "4.20",
-	ebpf.SkStorage:           "5.2",
-	ebpf.DevMapHash:          "5.4",
-	ebpf.StructOpsMap:        "5.6",
-	ebpf.RingBuf:             "5.8",
-	ebpf.InodeStorage:        "5.10",
-	ebpf.TaskStorage:         "5.11",
-}
-
 func TestHaveMapType(t *testing.T) {
-	for mt := ebpf.UnspecifiedMap + 1; mt <= mt.Max(); mt++ {
-		minVersion, ok := mapTypeMinVersion[mt]
-		if !ok {
-			// In cases were a new map type wasn't added to testCases
-			// we should make sure the test runs anyway and fails on old kernels
-			minVersion = "0.0"
-		}
-
-		feature := fmt.Sprintf("map type %s", mt.String())
-
-		t.Run(mt.String(), func(t *testing.T) {
-			testutils.SkipOnOldKernel(t, minVersion, feature)
-
-			if err := HaveMapType(mt); err != nil {
-				t.Fatalf("Map type %s isn't supported even though kernel is at least %s: %v", mt.String(), minVersion, err)
-			}
-		})
-	}
-}
-
-type haveMapFlagsTestEntry struct {
-	flags            MapFlags
-	minKernelVersion string
-	description      string
+	testutils.CheckFeatureMatrix(t, haveMapTypeMatrix)
 }
 
 func TestHaveMapFlag(t *testing.T) {
-	mapFlagTestEntries := []haveMapFlagsTestEntry{
-		{BPF_F_RDONLY_PROG, "5.2", "read_only_array_map"},
-		{BPF_F_WRONLY_PROG, "5.2", "write_only_array_map"},
-		{BPF_F_MMAPABLE, "5.5", "mmapable_array_map"},
-		{BPF_F_INNER_MAP, "5.10", "inner_map_array_map"},
-		{BPF_F_NO_PREALLOC, "4.6", "no_prealloc_hash_map"},
-	}
-
-	for _, entry := range mapFlagTestEntries {
-		t.Run(entry.description, func(t *testing.T) {
-			err := HaveMapFlag(entry.flags)
-			if testutils.IsKernelLessThan(t, entry.minKernelVersion) {
-				if err == nil {
-					t.Fatalf("Map flag %d is supported on this kernel even though kernel is less than %s", entry.flags, entry.minKernelVersion)
-				}
-			} else {
-				if err != nil {
-					t.Fatalf("Map flag %d isn't supported even though kernel is at least %s: %v", entry.flags, entry.minKernelVersion, err)
-				}
-			}
-		})
-	}
-}
-
-func TestHaveMapTypeUnsupported(t *testing.T) {
-	if err := haveMapType(ebpf.MapType(math.MaxUint32)); !errors.Is(err, ebpf.ErrNotSupported) {
-		t.Fatalf("Expected ebpf.ErrNotSupported but was: %v", err)
-	}
+	testutils.CheckFeatureMatrix(t, haveMapFlagsMatrix)
 }
 
 func TestHaveMapTypeInvalid(t *testing.T) {
-	if err := HaveMapType(ebpf.MapType(math.MaxUint32)); !errors.Is(err, os.ErrInvalid) {
-		t.Fatalf("Expected os.ErrInvalid but was: %v", err)
+	if err := HaveMapType(ebpf.MapType(math.MaxUint32)); err == nil {
+		t.Fatal("Expected an error")
+	} else if errors.Is(err, internal.ErrNotSupported) {
+		t.Fatal("Got ErrNotSupported:", err)
 	}
 }
diff --git a/features/misc_test.go b/features/misc_test.go
index 28bb132..95fa0e0 100644
--- a/features/misc_test.go
+++ b/features/misc_test.go
@@ -6,27 +6,18 @@ import (
 	"github.com/cilium/ebpf/internal/testutils"
 )
 
-func TestHaveMisc(t *testing.T) {
-	tests := map[string]struct {
-		typ       miscType
-		probe     func() error
-		minKernel string
-	}{
-		"large instructions": {typ: largeInsn, probe: HaveLargeInstructions, minKernel: "5.2"},
-		"bounded loops":      {typ: boundedLoops, probe: HaveBoundedLoops, minKernel: "5.3"},
-		"v2 ISA":             {typ: v2ISA, probe: HaveV2ISA, minKernel: "4.14"},
-		"v3 ISA":             {typ: v3ISA, probe: HaveV3ISA, minKernel: "5.1"},
-	}
+func TestHaveLargeInstructions(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveLargeInstructions)
+}
+
+func TestHaveBoundedLoops(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveBoundedLoops)
+}
 
-	for misc, test := range tests {
-		test := test
-		t.Run(misc, func(t *testing.T) {
-			testutils.SkipOnOldKernel(t, test.minKernel, misc)
+func TestHaveV2ISA(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveV2ISA)
+}
 
-			if err := test.probe(); err != nil {
-				t.Fatalf("Feature %s isn't supported even though kernel is at least %s: %v",
-					misc, test.minKernel, err)
-			}
-		})
-	}
+func TestHaveV3ISA(t *testing.T) {
+	testutils.CheckFeatureTest(t, HaveV3ISA)
 }
diff --git a/features/prog_test.go b/features/prog_test.go
index e4141af..c512c37 100644
--- a/features/prog_test.go
+++ b/features/prog_test.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 	"fmt"
 	"math"
-	"os"
 	"testing"
 
 	"github.com/cilium/ebpf"
@@ -13,86 +12,15 @@ import (
 	"github.com/cilium/ebpf/internal/testutils"
 )
 
-var progTypeMinVersion = map[ebpf.ProgramType]string{
-	ebpf.SocketFilter:          "3.19",
-	ebpf.Kprobe:                "4.1",
-	ebpf.SchedCLS:              "4.1",
-	ebpf.SchedACT:              "4.1",
-	ebpf.TracePoint:            "4.7",
-	ebpf.XDP:                   "4.8",
-	ebpf.PerfEvent:             "4.9",
-	ebpf.CGroupSKB:             "4.10",
-	ebpf.CGroupSock:            "4.10",
-	ebpf.LWTIn:                 "4.10",
-	ebpf.LWTOut:                "4.10",
-	ebpf.LWTXmit:               "4.10",
-	ebpf.SockOps:               "4.13",
-	ebpf.SkSKB:                 "4.14",
-	ebpf.CGroupDevice:          "4.15",
-	ebpf.SkMsg:                 "4.17",
-	ebpf.RawTracepoint:         "4.17",
-	ebpf.CGroupSockAddr:        "4.17",
-	ebpf.LWTSeg6Local:          "4.18",
-	ebpf.LircMode2:             "4.18",
-	ebpf.SkReuseport:           "4.19",
-	ebpf.FlowDissector:         "4.20",
-	ebpf.CGroupSysctl:          "5.2",
-	ebpf.RawTracepointWritable: "5.2",
-	ebpf.CGroupSockopt:         "5.3",
-	ebpf.Tracing:               "5.5",
-	ebpf.StructOps:             "5.6",
-	ebpf.Extension:             "5.6",
-	ebpf.LSM:                   "5.7",
-	ebpf.SkLookup:              "5.9",
-	ebpf.Syscall:               "5.14",
-}
-
 func TestHaveProgramType(t *testing.T) {
-	for progType := ebpf.UnspecifiedProgram + 1; progType <= progType.Max(); progType++ {
-		// Need inner loop copy to make use of t.Parallel()
-		pt := progType
-
-		minVersion, ok := progTypeMinVersion[pt]
-		if !ok {
-			// In cases where a new prog type wasn't added to progTypeMinVersion
-			// we should make sure the test runs anyway and fails on old kernels
-			minVersion = "0.0"
-		}
-
-		feature := fmt.Sprintf("program type %s", pt.String())
-
-		t.Run(pt.String(), func(t *testing.T) {
-			t.Parallel()
-
-			if progLoadProbeNotImplemented(pt) {
-				t.Skipf("Test for prog type %s requires working probe", pt.String())
-			}
-			testutils.SkipOnOldKernel(t, minVersion, feature)
-
-			if err := HaveProgramType(pt); err != nil {
-				if pt == ebpf.LircMode2 {
-					// CI kernels are built with CONFIG_BPF_LIRC_MODE2, but some
-					// mainstream distro's don't ship with it. Make this prog type
-					// optional to retain compatibility with those kernels.
-					testutils.SkipIfNotSupported(t, err)
-				}
-
-				t.Fatalf("Program type %s isn't supported even though kernel is at least %s: %v", pt.String(), minVersion, err)
-			}
-		})
-
-	}
-}
-
-func TestHaveProgramTypeUnsupported(t *testing.T) {
-	if err := haveProgramType(ebpf.ProgramType(math.MaxUint32)); !errors.Is(err, ebpf.ErrNotSupported) {
-		t.Fatalf("Expected ebpf.ErrNotSupported but was: %v", err)
-	}
+	testutils.CheckFeatureMatrix(t, haveProgramTypeMatrix)
 }
 
 func TestHaveProgramTypeInvalid(t *testing.T) {
-	if err := HaveProgramType(ebpf.ProgramType(math.MaxUint32)); !errors.Is(err, os.ErrInvalid) {
-		t.Fatalf("Expected os.ErrInvalid but was: %v", err)
+	if err := HaveProgramType(ebpf.ProgramType(math.MaxUint32)); err == nil {
+		t.Fatal("Expected an error")
+	} else if errors.Is(err, internal.ErrNotSupported) {
+		t.Fatal("Got ErrNotSupported:", err)
 	}
 }
 
@@ -135,26 +63,10 @@ func TestHaveProgramHelper(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		minVersion := progTypeMinVersion[tc.prog]
-
-		progVersion, err := internal.NewVersion(minVersion)
-		if err != nil {
-			t.Fatalf("Could not read kernel version required for program: %v", err)
-		}
-
-		helperVersion, err := internal.NewVersion(tc.version)
-		if err != nil {
-			t.Fatalf("Could not read kernel version required for helper: %v", err)
-		}
-
-		if progVersion.Less(helperVersion) {
-			minVersion = tc.version
-		}
-
 		t.Run(fmt.Sprintf("%s/%s", tc.prog.String(), tc.helper.String()), func(t *testing.T) {
 			feature := fmt.Sprintf("helper %s for program type %s", tc.helper.String(), tc.prog.String())
 
-			testutils.SkipOnOldKernel(t, minVersion, feature)
+			testutils.SkipOnOldKernel(t, tc.version, feature)
 
 			err := HaveProgramHelper(tc.prog, tc.helper)
 			if !errors.Is(err, tc.expected) {
@@ -165,16 +77,3 @@ func TestHaveProgramHelper(t *testing.T) {
 
 	}
 }
-
-func TestHaveProgramHelperUnsupported(t *testing.T) {
-	pt := ebpf.SocketFilter
-	minVersion := progTypeMinVersion[pt]
-
-	feature := fmt.Sprintf("program type %s", pt.String())
-
-	testutils.SkipOnOldKernel(t, minVersion, feature)
-
-	if err := haveProgramHelper(pt, asm.BuiltinFunc(math.MaxInt32)); !errors.Is(err, ebpf.ErrNotSupported) {
-		t.Fatalf("Expected ebpf.ErrNotSupported but was: %v", err)
-	}
-}
diff --git a/internal/feature_test.go b/internal/feature_test.go
index 68b7ed0..4039256 100644
--- a/internal/feature_test.go
+++ b/internal/feature_test.go
@@ -9,7 +9,7 @@ import (
 func TestFeatureTest(t *testing.T) {
 	var called bool
 
-	fn := FeatureTest("foo", "1.0", func() error {
+	fn := NewFeatureTest("foo", "1.0", func() error {
 		called = true
 		return nil
 	})
@@ -27,7 +27,7 @@ func TestFeatureTest(t *testing.T) {
 		t.Error("Unexpected negative result:", err)
 	}
 
-	fn = FeatureTest("bar", "2.1.1", func() error {
+	fn = NewFeatureTest("bar", "2.1.1", func() error {
 		return ErrNotSupported
 	})
 
@@ -54,7 +54,7 @@ func TestFeatureTest(t *testing.T) {
 		t.Error("Didn't cache an error wrapping ErrNotSupported")
 	}
 
-	fn = FeatureTest("bar", "2.1.1", func() error {
+	fn = NewFeatureTest("bar", "2.1.1", func() error {
 		return errors.New("foo")
 	})
 
diff --git a/internal/testutils/feature.go b/internal/testutils/feature.go
index efece22..0db609e 100644
--- a/internal/testutils/feature.go
+++ b/internal/testutils/feature.go
@@ -17,9 +17,10 @@ func MustKernelVersion() internal.Version {
 }
 
 func CheckFeatureTest(t *testing.T, fn func() error) {
-	t.Helper()
+	checkFeatureTestError(t, fn())
+}
 
-	err := fn()
+func checkFeatureTestError(t *testing.T, err error) {
 	if err == nil {
 		return
 	}
@@ -32,6 +33,16 @@ func CheckFeatureTest(t *testing.T, fn func() error) {
 	}
 }
 
+func CheckFeatureMatrix[K comparable](t *testing.T, fm internal.FeatureMatrix[K]) {
+	t.Helper()
+
+	for key, ft := range fm {
+		t.Run(ft.Name, func(t *testing.T) {
+			checkFeatureTestError(t, fm.Result(key))
+		})
+	}
+}
+
 func SkipIfNotSupported(tb testing.TB, err error) {
 	tb.Helper()