FForge
← Labsaarch64-04AArch64 and SVE●●●○

Runtime dispatch on CPU features — HWCAP, ifunc, and validating the choice

aarch64/04_dispatch.cneeds: tools/a64.sh, qemu with -cpu max and -cpu cortex-a572 steps
0/2 steps
what this lab teaches

One binary, two implementations of the same kernel (a NEON dot product and an SVE one), and the two standard ways to pick at run time: A. a GNU ifunc: the dynamic loader calls your resolver once, at load time, and binds the symbol to the implementation it returns. On AArch64 the resolver receives the HWCAP bits as its argument. B. a function pointer set at startup from getauxval(AT_HWCAP). The SVE implementation is compiled with __attribute__((target("sve"))) inside an armv8-a translation unit, so the SVE instructions are present in the binary but only executed when chosen. Then the part people skip: the program checks that all three paths agree with a scalar reference.

evidence to leave with
Same binary picks dot_sve on max and dot_neon on a57 with identical results; IFUNC symbol shown
first, get the code
run this
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
then build it
run this
$ cd aarch64 && ./build.sh
produces bin/04_dispatch
walk it, one step at a time
run this
$ a64run -cpu max ./bin/04_dispatch
what it printed on the machine that wrote this lab
HWCAP sve=1 -> chosen: dot_sve  | ifunc=5255.250 ptr=5255.250 neon=5255.250 ref=5255.250 | max abs diff=-0
run this
$ a64run -cpu cortex-a57 ./bin/04_dispatch
what it printed on the machine that wrote this lab
HWCAP sve=0 -> chosen: dot_neon | ifunc=5255.250 ptr=5255.250 neon=5255.250 ref=5255.250 | max abs diff=-0

Same binary, different implementation, identical answer, no SIGILL on the old core (compare aarch64 lab 03's LSE build).

run this
$ a64s -O2 -march=armv8-a 04_dispatch.c | awk '/^resolve_dot:/,/\.Lfunc_end/'
what it printed on the machine that wrote this lab
  resolve_dot:
      adrp x8, dot_sve ; add x8, x8, :lo12:dot_sve
      tst  x0, #0x400000                     // HWCAP_SVE is bit 22
      adrp x9, dot_neon ; add x9, x9, :lo12:dot_neon
      csel x0, x9, x8, eq                    // x0 = (hwcap & SVE) ? dot_sve : dot_neon
      ret
run this
$ llvm-readelf -s bin/04_dispatch | grep -E ' dot$| resolve_dot| dot_sve| dot_neon'
what it printed on the machine that wrote this lab
      18: ... FUNC    LOCAL  DEFAULT   14 dot_sve
      19: ... FUNC    LOCAL  DEFAULT   14 dot_neon
      21: ... FUNC    LOCAL  DEFAULT   14 resolve_dot
      40: ... IFUNC   GLOBAL DEFAULT   14 dot
  `dot` has symbol type IFUNC: the loader will call resolve_dot (whose
  address is where a function would be) and patch the GOT/PLT slot with
  the result. After that, calling dot() costs the same as any call
  through the PLT. This is exactly how glibc picks memcpy/strlen variants.

ifunc VS FUNCTION POINTER:
  ifunc: no visible dispatch code at call sites; resolved once; requires
  glibc-style dynamic linking (static-PIE and musl need care), runs before
  main with limited environment (no malloc, no TLS), and is invisible to
  tests unless you also export the variants (as this lab does: dot_neon
  and dot_sve are callable directly).
  function pointer: explicit, testable (call every variant in your test
  suite regardless of the host), works everywhere, and lets a config flag
  or benchmark override the choice. One indirect call per use. Most
  performance libraries (BLAS, codecs) use this or a per-object-file
  variant table for the same reasons.

VALIDATING A DISPATCH DECISION (the part that matters in real work): 1. Correctness: every variant against a scalar reference, on inputs whose size is not a multiple of any vector length, under qemu at several VLs (lab 02) and on the hardware you have. This lab prints the max abs difference; a real test uses a tolerance and many inputs. 2. Benefit: on the target hardware, the chosen variant must actually be faster on the sizes the workload uses (small-n overhead, alignment, the cost of svwhilelt per iteration). "Has SVE" is not "SVE is faster". 3. Stability: the choice must not flip between runs (pinned feature detection), and must degrade safely (an unknown core gets the baseline). Log the decision once at startup. That is the checklist for "should this API be redirected to the SVE / SME kernel": evidence at each step, not the presence of a feature bit.

the rules

D1. Detect with getauxval(AT_HWCAP/AT_HWCAP2) (or /proc/cpuinfo for humans); never assume from -march. D2. Keep variants callable and tested individually; dispatch is a thin layer on top. D3. Prove the chosen variant is correct AND faster on the real target before wiring the dispatch; keep an override for benchmarking.

take it further

1. Set n to 4 and to 3: the SVE path's whilelt handles both; compare with the NEON path's scalar tail. 2. Add a dot_sve2 variant with target("sve2") and dispatch on HWCAP2_SVE2; run on `-cpu neoverse-v1` (SVE only) vs `-cpu max`. 3. `LD_DEBUG=bindings a64run -cpu max ./bin/04_dispatch 2>&1 | grep dot`: watch the loader bind the ifunc. ============================================================

read the source · aarch64/04_dispatch.c
#include <stdio.h> #include <stdlib.h> #include <sys/auxv.h> #include <asm/hwcap.h> #include <arm_neon.h> #include <arm_sve.h> /* usable at file scope without +sve; intrinsics allowed inside target("sve") functions */ /* Two implementations of the same kernel. The SVE one is compiled with a per-function target attribute, * so this TU stays a plain armv8-a build: the SVE instructions exist in the binary but run only if chosen. */ __attribute__((noinline)) static float dot_neon(const float *a, const float *b, long n) { float32x4_t acc = vdupq_n_f32(0); long i = 0; for (; i + 4 <= n; i += 4) acc = vfmaq_f32(acc, vld1q_f32(a + i), vld1q_f32(b + i)); float s = vaddvq_f32(acc); for (; i < n; i++) s += a[i] * b[i]; return s; } __attribute__((noinline, target("sve"))) static float dot_sve(const float *a, const float *b, long n) { svfloat32_t acc = svdup_f32(0); for (long i = 0; i < n; i += svcntw()) { svbool_t pg = svwhilelt_b32(i, n); acc = svmla_f32_x(pg, acc, svld1(pg, a + i), svld1(pg, b + i)); } return svaddv_f32(svptrue_b32(), acc); } /* Option A: an ifunc. glibc calls the resolver once at load time; on AArch64 it receives hwcap as its argument. */ typedef float (*dot_fn)(const float *, const float *, long); static dot_fn resolve_dot(unsigned long hwcap) { return (hwcap & HWCAP_SVE) ? dot_sve : dot_neon; } float dot(const float *a, const float *b, long n) __attribute__((ifunc("resolve_dot"))); /* Option B: an explicit function pointer chosen at startup (portable, testable, no loader magic). */ static dot_fn g_dot; int main(int argc, char **argv) { long n = argc > 1 ? atol(argv[1]) : 1001; unsigned long hw = getauxval(AT_HWCAP); g_dot = (hw & HWCAP_SVE) ? dot_sve : dot_neon; float *a = malloc(n * 4), *b = malloc(n * 4); double ref = 0; for (long i = 0; i < n; i++) { a[i] = (float)(i % 13) * 0.5f; b[i] = (float)(i % 7) * 0.25f + 1.0f; ref += (double)a[i] * b[i]; } float r_ifunc = dot(a, b, n), r_ptr = g_dot(a, b, n), r_neon = dot_neon(a, b, n); printf("HWCAP sve=%d -> chosen: %s | ifunc=%.3f ptr=%.3f neon=%.3f ref=%.3f | max abs diff=%.3g\n", !!(hw & HWCAP_SVE), (hw & HWCAP_SVE) ? "dot_sve" : "dot_neon", r_ifunc, r_ptr, r_neon, ref, (double)(r_ifunc - (float)ref) > 0 ? (double)(r_ifunc - (float)ref) : -(double)(r_ifunc - (float)ref)); return 0; }
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.