https://herbsutter.com/2012/05/03/reader-qa-what-about-vc-an...
Even if in recent years after tbat post they added support for C11 and C17, minus some stuff like aligned mallocs.
I've been building some software recently whose performance is very sensitive to the capabilities of the CPU on which it's running. A portable version of the code does not perform all that well, but we cannot guarantee the presence of optional Instruction Set Architectures (ISAs) which we can use to speed it up. What to do? That's what we'll be looking at today, mostly for the wildly popular x86-64 family of processors (but the general techniques apply anywhere).
Compilers are very good at optimising for a particular target CPU microarchitecture, such as if you use -march=native (or e.g. -march=znver3). They know amongst other things, the ISA capabilities of these CPUs and they will quietly take advantage of them at cost of portability.
So the first way to speed up C software is to build for a more recent architecture where the compiler has the tools to speed the code up for you. This won't work for every problem or scenario, but if it's an option for you, it's very easy.
This works surprisingly well on x86-64 because it's now a very mature architecture. But this also means that there's a wide span of capabilities between the original x86-64 CPUs and the CPUs you can buy nowadays. To help make things a bit more digestible, intel devised microarchitecture levels, with later levels including all the features of its predecessors:
| Level | Contains e.g. | Intel | AMD |
|---|---|---|---|
| x86-64-v1 | (base) | All 64 bit | All 64 bit |
| x86-64-v2 | POPCNT, SSE4.2 | 2008 (Nehalem/Westmere) | 2011 (Bulldozer) |
| x86-64-v3 | AVX2, BMI2 | 2013 (Haswell/Broadwell) | 2015 (Excavator) |
| x86-64-v4 | AVX-512[1] | 2017 (Skylake) | 2022 (Zen 4) |
[1] AVX-512 is not actually one feature, but v4 includes the most useful parts of it.
There are some gotchas I won't dwell on, but not all kit released after these dates is good for these capabilities, in particular there have been:
However, in general, microarchitecture levels give you a good set of baseline capabilities for optimisation. Two ways to use them:
Obviously the second is less than ideal if you don't control all the hardware you can run on. Fortunately there's an answer for that (for popular compilers): indirect functions (IFUNCs). IFUNCs essentially have the dynamic linker run a function at link time which returns the real function to use according to the hardware available. And the best bit is for the general case, the compiler can even do all the work for you:
[[gnu::target_clones("avx2,default")]] // gcc/glibc and clang void * my_func(void *data) { ... }
Note that the square brackets here are c23 syntax for attributes. the equivalent compiler-specific version is __attribute__((target_clones("avx2,default"))). It's the little things that make c23 great!
This will create two versions of my_func, one with avx2 and one with the default flags. It will also generate a resolver function in the background for the dynamic linker to run. Calls to the function will thus be linked to the most optimal version at program startup time!
If you're lucky, this did the trick. If you're slightly less lucky you may have luck triggering autovectorisation with some small modifications (such as alignment annotations). Sadly this process is finicky and unreliable and there isn't really space for it in this post.
Sometimes you need to write multiple versions of an algorithm to get the best performance. Either you can't autovec to work (if it's for SIMD) or you need to work with some specific intrinsics (such as I do for this project).
To take advantage of intrinsics directly, we must provide two versions of an algorithm: a portable version and a version that uses the intrinsics. Here's how we might optimise for AVX2 statically:
#ifdef __AVX2__ // defined by the compiler when AVX2 is supported on the target #include <immintrin.h> // header with avx2 intrinsics void * my_func(void *data) { ... } #else void * my_func(void *data) { ... } #endif
With this sort of technique, we can once again support building for targets with specific capabilities, except now with direct access to the intrinsics that can make things faster.
But we're still building for a specific target and we'd like not to do that. Unfortunately there isn't a portable way to do this, but there are compiler-specific hacks. Here's how we do it for gcc and clang for avx2:
// ask the compiler to enable avx2 #pragma GCC push_options #pragma GCC target ("avx2") #pragma clang attribute push \ (__attribute__((target("avx2"))), apply_to = function)
// now include the header with avx2 enabled #include <immintrin.h>
// now undo to stop our portable code requiring avx2 #pragma GCC pop_options #pragma clang attribute pop
[[gnu::target("avx2")]] // this function must be compiled for avx2 void * my_func_avx2(void *data) { ... } void * my_func_portable(void *data) { ... }
Now we need a way to dispatch between them. As we are limiting ourselves to gcc and clang on x86-64, we can use the compiler-provided runtime platform detection to switch implementations:
void * my_func(void *data) { return __builtin_cpu_supports("avx") ? my_func_avx2(data) : my_func_portable(data); }
We could use IFUNCs instead, although we have to write our own resolver this time:
static void * (*resolve_my_func(void)) (void *) { __builtin_cpu_init(); // ifunc resolvers are called before this is automatically triggered. return __builtin_cpu_supports("avx") ? my_func_avx2 : my_func_portable; } void * my_func(void *data) __attribute__ ((ifunc ("resolve_my_func")));
Okay it's a bit gnarly because of the atrocious function pointer syntax in c, but it works. At program startup, this will pick the best version!
At this point, since we're writing our own resolver function, we can provide any logic we like over as many different versions as we like. This makes it possible to handle more complex scenarios such as working around AMD's BMI2 implementation being slow before Zen 3 or Intel's AVX-512 implementation aggressively downclocking the CPU when you use ZMM registers before ice lake. Or probably the scenario you find yourself in, if your luck is anything like mine.
MUSL libc does not (yet) support IFUNCS. It's not a simple feature.
I haven't said a word about windows support. I do not have a windows machine to test on and in any case, the project I'm doing this for is written in C23, while the compiler of choice for windows (outside of WSL), MSVC, supports most of C11. You'd be forgiven for thinking microslop don't actually want people to port C software to windows!