Gromacs  2020.4
 All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Groups Pages
Single-instruction Multiple-data (SIMD) coding

Coding with SIMD instructions

One important way for GROMACS to achieve high performance is to use modern hardware capabilities where a single assembly instruction operates on multiple data units, essentially short fixed-length vectors (usually 2, 4, 8, or 16 elements). This provides a very efficient way for the CPU to increase floating-point performance, but it is much less versatile than general purpose registers. For this reason it is difficult for the compiler to generate efficient SIMD code, so the user has to organize the data in a way where it is possible to access as vectors, and these vectors often need to be aligned on cache boundaries.

We have supported a number of different SIMD instruction sets in the group kernels for ages, and it is now also present in the Verlet kernels and a few other places. However, with the increased usage and several architectures with different capabilities we now use a vendor-agnostic GROMACS SIMD module, as documented in SIMD intrinsics interface (simd).

Design of the GROMACS SIMD module

The functions in src/gromacs/simd are intended to be used for writing architecture-independent SIMD intrinsics code. Rather than making assumptions based on architecture, we have introduced a limited number of predefined preprocessor macros that describe the capabilities of the current implementation - these are the ones you need to check when writing SIMD code. As you will see, the functionality exposed by this module as typically a small subset of general SIMD implementations, and in particular we do not even try to expose advanced shuffling or permute operations, simply because we haven't been able to describe those in a generic way that can be implemented efficiently regardless of the hardware. However, the advantage of this approach is that it is straightforward to extend with support for new simd instruction sets in the future, and that will instantly speed up old code too.

To support the more complex stuff in the GROMACS nonbonded kernels and to make it possible to use SIMD intrinsics even for some parts of the code where the data is not in SIMD-friendly layout, we have also added about 10 higher-level utility routines. These perform gather/scatter operations on coordinate triplets, they load table data and aligned pairs (Lennard-Jones parameters), and sum up the forces needed in the outer loop of the nonbonded kernels. They are very straightforward to implement, but since they are performance-critical we want to exploit all features of each architecture, and for this reason they are part of the SIMD implementation.

Finally, for some architectures with large or very large SIMD width (e.g. AVX with 8 elements in single precision, or AVX-512 with 16), the nonbonded kernels can become inefficient. Since all such architectures presently known (AVX, AVX2, MIC, AVX512) also provide extensive support for accessing parts of the register, we optionally define a handful of routines to perform load, store, and reduce operations based on half-SIMD-width data, which can improve performance. It is only useful for wide implementations, and it can safely be ignored first when porting to new platforms - they are only needed for the so-called 2xnn SIMD kernels.

Unfortunately there is no standard for SIMD architectures. The available features vary a lot, but we still need to use quite a few of them to get the best performance possible. This means some features will only be available on certain platforms, and it is critical that we do NOT make too many assumptions about the storage formats, their size or SIMD width. Just to give a few examples:

While this might sound complicated, it is actually far easier than writing separate SIMD code for 10 architectures in both single & double. The point is not that you need to remember the limitations above, but it is critical that you never assume anything about the SIMD implementation. We typically implement SIMD support for a new architecture in days with this new module, and the extensions required for Verlet kernels are also very straightforward (group kernels can be more complex, but those are gradually on their way out). For the higher-level code, the only important thing is to never assume anything about the SIMD architecture. Our general strategy in GROMACS is to split the SIMD coding in three levels:

Base level generic SIMD
The base level SIMD module (which we get by including gromacs/simd/simd.h provides the API to define and manipulate SIMD datatypes. This will be enough for lots of cases, and it is a huge advantage that there is roughly parity between different architectures.
Higher-level architecture-specific SIMD utility functions
For some parts of the code this is not enough. In particular, both the group and Verlet kernels do insane amounts of floating-point operations, and since we spend 85-90% of the time in these kernels it is critical that we can optimize them as much as possible. Here, our strategy is first to define larger high-level functions that e.g. take a number of distances and load the table interactions for this interaction. This way we can move this architecture-specific implementation to the SIMD module, and both achieve a reasonably clean kernel but still optimize a lot. This is what we have done for the approximately 10 functions for the nonbonded kernels, to load tables and Lennard-Jones parameters, and to sum up the forces in the outer loop. These functions have intentionally been given names that describe what they do with the data, rather than what their function is in GROMACS. By looking at the documentation for these routines, and the reference implementation, it should be quite straightforward to implement them for a new architecture too.
Half-SIMD-width architecture-specific utility functions
As described earlier, as the SIMD width increases to 8 or more elements, the nonbonded kernels can become inefficient due to the large j-particle cluster size. Things will still work, but if an architecture supports efficient access to partial SIMD registers (e.g. loading half the width), we can use this to alter the balance between memory load/store operations and floating-point arithmetic operations by processing either e.g. 4-by-4 or 2-by-8 interactions in one iteration. When GMX_SIMD_HAVE_HSIMD_UTIL_REAL is set, a handful of routines to use this in the nonbonded kernels is present. Avoid using these routines outside the nonbonded kernels since they are slightly more complex, and is is not straightforward to determine which alternative provides the best performance.
Architecture-specific kernels (directories/files)
No code outside the SIMD module implementation directories should try to execute anything hardware specific. Note that this includes even checking for what architecture the current SIMD implementation is - you should check for features instead, so it will work with future ports too.

File organization

The SIMD module uses a couple of different files:

This is the top-level wrapper that you should always include first. It will check the settings made at configuration time and include a suitable low-level implementation (that can be either single, double, or both). It also contains the routines for memory alignment, and based on the current GROMACS precision it will set aliases to 'real' SIMD datatypes (see further down) so the implementations do not have to care about GROMACS-specific details. However, note that you might not get all SIMD support you hoped for: If you compiled GROMACS in double precision but the hardware only supports single-precision SIMD there will not be any SIMD routines for default GROMACS 'real' precision. There are #defines you can use to check this, as described further down.
This is an example of a low-level implementation. You should never, ever, work directly with these in higher-level code. The reference implementation contains the documentation for all SIMD wrappers, though. This file will in turn include other separate implementation files for single, double, simd4, etc. Since we want to be able to run the low-level SIMD implementation in simulators for new platforms, these files are intentionally not using the rest of the GROMACS infrastructure, e.g. for asserts().
SIMD math functions. All functions in this file have to be designed so they work no matter whether the hardware supports integer SIMD, logical operations on integer or floating-point SIMD, or arithmetic operations on integers. However, a few routines check for defines and use faster algorithms if these features are present.
This file contains a few rvec-related SIMD functions, e.g. to calculate scalar products, norms, or cross products. They obviously cannot operate on scalar GROMACS rvec types, but use separate SIMD variables for X,Y, and Z vector components.

SIMD datatypes

The SIMD module handles the challenges mentioned in the introduction by introducing a number of datatypes; many of these might map to the same underlying SIMD types, but we need separate types because some architectures use different registers e.g. for boolean types.

Floating-point data

This is the SIMD-version of GROMACS' real type, which is set based on the CMake configuration and internally aliased to one of the next two types.
This is always single-precision data, but it might not be supported on all architectures.
This is always double precision when available, and in rare cases you might want to use a specific precision.

Integers corresponding to floating-point values

For these types, 'correspond' means that it is the integer type we get when we convert data e.g. from single (or double) precision floating-point SIMD variables. Those need to be different, since many common implementations only use half as many elements for double as for single SIMD variables, and then we only get half the number of integers too.

This is used for integers when converting to/from GROMACS default "real" type.
Integers obtained when converting from single precision, or intended to be converted to single precision floating-point. These are normal integers (not a special conversion type), but since some SIMD architectures such as SSE or AVX use different registers for integer SIMD variables having the same width as float and double, respectively, we need to separate these two types of integers. The actual operations you perform on them are normal ones such as addition or multiplication. This will also be the widest integer data type if you want to do pure integer SIMD operations, but that will not be supported on all platforms. If the architecture does not support any SIMD integer type at all, this will likely be defined from the floating-point SIMD type, without support for any integer operations apart from load/store/convert.
Integers used when converting to/from double. See the preceding item for a detailed explanation. On many architectures, including all x86 ones, this will be a narrower type than gmx::SimdFInt32.

Note that all integer load/stores operations defined here load/store 32-bit integers, even when the internal register storage might be 64-bit, and we set the "width" of the SIMD implementation based on how many float/double/ integers we load/store - even if the internal width could be larger.

Boolean values

We need a separate boolean datatype for masks and comparison results, since we cannot assume they are identical either to integers, floats or double - some implementations use specific predicate registers for booleans.

Results from boolean operations involving reals, and the booleans we use to select between real values. The corresponding routines have suffix B, like gmx::simdOrB().
Booleans specifically for single precision.
Operations specifically on double.
Boolean operations on integers corresponding to real (see floating-point descriptions above).
Booleans for integers corresponding to float.
Booleans for integers corresponding to double.

Note: You should NOT try to store and load boolean SIMD types to memory - that is the whole reason why there are no store or load operations provided for them. While it will be technically possible to achieve by defining objects inside a structure and then doing a placement new with aligned memory, this can be a very expensive operation on platforms where special single-bit predicate registers are used to represent booleans. You will need to find a more portable algorithm for your code instead.

The subset you should use in practice

If this seems daunting, in practice you should only need to use these types when you start coding:

Floating-point data.
Integer data. Might not be supported, so you must check the preprocessor macros described below.

Operations on these types will be defined to either float/double (or corresponding integers) based on the current GROMACS precision, so the documentation is occasionally more detailed for the lower-level actual implementation functions.

Note that it is critical for these types to be aligned in memory. This should always be the case when you declare variables on the stack, but unfortunately some compilers (at least clang-3.7 on OS X) appear to be buggy when our SIMD datatypes are placed inside a structure. Somewhere in the processes where this structure includes our class, which in turn includes the actual SIMD datatype, the alignment appears to be lost. Thus, even though the compiler will not warn you, until further notice we need to avoid putting the SIMD datatypes into other structures. This is particular severe when allocating memory on the heap, but it occurs for stack structures/classes too.

SIMD4 implementation

The above should be sufficient for code that works with the full SIMD width. Unfortunately reality is not that simple. Some algorithms like lattice summation need quartets of elements, so even when the SIMD width is >4 we need width-4 SIMD if it is supported. The availability of SIMD4 is indicated by GMX_SIMD4_HAVE_FLOAT and GMX_SIMD4_HAVE_DOUBLE. For now we only support a small subset of SIMD operations for SIMD4. Because SIMD4 doesn't scale with increasingly large SIMD width it should be avoided for all new code and SIMD4N should be used instead.

SIMD4N implementation

Some code, like lattice summation, has inner loops which are smaller than the full SIMD width. In GROMACS algorithms 3 and 4 iterations are common because of PME order and three dimensions. This makes 4 an important special case. Vectorizing such loops efficiently requires to collapse the two most inner loops and using e.g. one 8-wide SIMD vector for 2 outer and 4 inner iterations or one 16-wide SIMD vector for 4 outer and 4 inner iterations. For this SIMD4N functions are provided. The availability of these function is indicated by GMX_SIMD_HAVE_4NSIMD_UTIL_FLOAT and GMX_SIMD_HAVE_4NSIMD_UTIL_DOUBLE. These functions return the type alias Simd4NFloat / Simd4NDouble which is either the normal SIMD type or the SIMD4 type and thus only supports the operations the SIMD4 type supports.

Predefined SIMD preprocessor macros

Functionality-wise, we have a small set of core features that we require to be present on all platforms, while more avanced features can be used in the code when defines like e.g. GMX_SIMD_HAVE_LOADU have the value 1.

This is a summary of the currently available preprocessor defines that you should use to check for support when using the corresponding features. We first list the float/double/int defines set by the implementation; in most cases you do not want to check directly for float/double defines, but you should instead use the derived "real" defines set in this file - we list those at the end below.

Preprocessor predefined macro defines set by the low-level implementation. These only have the value 1 if they work for all datatypes; GMX_SIMD_HAVE_LOADU thus means we can load both float, double, and integers from unaligned memory, and that the unaligned loads are available for SIMD4 too.

Some sort of SIMD architecture is enabled.
Single-precision instructions available.
Double-precision instructions available.
Load from unaligned memory available.
Store to unaligned memory available.
Support for and/andnot/or/xor on floating-point variables.
Floating-point fused multiply-add. Note: We provide emulated FMA instructions if you do not have FMA support, but in that case you might be able to code it more efficient w/o FMA.
Support for extracting integer SIMD elements from gmx::SimdFInt32.
Bitwise shifts on gmx::SimdFInt32.
Arithmetic ops for gmx::SimdFInt32.
Support for extracting integer SIMD elements from gmx::SimdDInt32.
Bitwise shifts on gmx::SimdDInt32.
Arithmetic ops for gmx::SimdDInt32.
Half-SIMD-width nonbonded kernel utilities available for float SIMD.
Half-SIMD-width nonbonded kernel utilities available for double SIMD.
Can load pairs of unaligned floats from simd offsets (meant for linear tables).
Can load pairs of unaligned doubles from simd offsets (meant for linear tables).

There are also two macros specific to SIMD4: GMX_SIMD4_HAVE_FLOAT is set if we can use SIMD4 in single precision, and GMX_SIMD4_HAVE_DOUBLE similarly denotes support for a double-precision SIMD4 implementation. For generic properties (e.g. whether SIMD4 FMA is supported), you should check the normal SIMD macros above.

Implementation properties

Higher-level code can use these macros to find information about the implementation, for instance what the SIMD width is:

Number of elements in gmx::SimdFloat, and practical width of gmx::SimdFInt32.
Number of elements in gmx::SimdDouble, and practical width of gmx::SimdDInt32
Accuracy (bits) of 1/sqrt(x) lookup step.
Accuracy (bits) of 1/x lookup step.

After including the low-level architecture-specific implementation, this header sets the following derived defines based on the current precision; these are the ones you should check for unless you absolutely want to dig deep into the explicit single/double precision implementations:


For convenience we also define GMX_SIMD4_WIDTH to 4. This will never vary, but using it helps you make it clear that a loop or array refers to the SIMD4 width rather than some other '4'.

While all these defines are available to specify the features of the hardware, we would strongly recommend that you do NOT sprinkle your code with defines - if nothing else it will be a debug nightmare. Instead you can write a slower generic SIMD function that works everywhere, and then override this with faster architecture-specific versions for some implementations. The recommended way to do that is to add a define around the generic function that skips it if the name is already defined. The actual implementations in the lowest-level files are typically defined to an architecture-specific name (such as simdSinCosD_Sse2) so we can override it (e.g. in SSE4) by simply undefining and setting a new definition. Still, this is an implementation detail you won't have to worry about until you start writing support for a new SIMD architecture.

Function naming

We rely on C++ overloading, so the name of a function is usually identical regardless of what datatype it operates on. There are a few exceptions to this for functions that do not take arguments but only return a value, e.g. setZero(), since overloading only works if the formal parameters are different. To solve this, we use different low-level function names in these cases, but then create proxy objects in the high-level gromacs/simd/simd.h so that you can still get the functionality by simply writing setZero() in the code.

Automated checking

Having fallback implementations when SIMD is not supported can be a performance problem if the code does not correctly include gromacs/simd/simd.h, particularly after refactoring. make check-source checks the whole code for the use of symbols defined in gromacs/simd/simd.h and requires that files using those symbols do the correct include. Similar checking is done for higher-level SIMD-management headers, e.g. gromacs/ewald/pme_simd.h.

The SIMD math library

In addition to the low-level SIMD instructions, GROMACS comes with a fairly extensive SIMD math library in gromacs/simd/simd_math.h to support various mathematical functions. The functions are available both in single and double precision (overloaded on the usual math function names), and we also provide a special version of functions that use double precision arguments, but that only evaluate the result to single precision accuracy. This is useful when you don’t need highly accurate results, but you want to avoid the overhead of doing multiple single/double conversions, or if the hardware architecture only provides a double precision SIMD implementation.

For a few functions such as the square root and exponential that are performance-critical, we provide additional tempate parameters where the default choice is to execute the normal function version, but it is also possible to choose an unsafe execution path that completely bypass all argument checking. Make absolutely sure your arguments always fulfil the restrictions listed in the documentation of such a function before using it, and it might even be a good idea to add a note before each call to an unsafe function justifying why that flavor is fine to use here.