Optimizing 1D Convolution with AVX2 and AVX-512 Intrinsics

Architectural Enhancements in AVX2

Transitioning from baseline AVX to AVX2 introduces several instruction set extensions that directly benefit digital signal processing and compute-heavy workloads. While original AVX focused primarily on 256-bit floating-point SIMD operations, AVX2 expands the execution model in the following ways:

  • Full 256-bit Integer SIMD: AVX2 extends integer vector operations to match the 256-bit width of floating-point registers, enabling paralel processing of 8x32-bit or 16x16-bit integers per cycle.
  • FMA3 Integration: Fused Multiply-Add instructions combine multiplication and addition into a single micro-operation. This reduces instruction latency, improves throughput, and minimizes rounding errors compared to separate mul and add sequences.
  • Gather Instructions: Hardware-supported gather operations allow loading non-contiguous memory elements into a single vector register, simplifying sparse data access and eliminating complex scalar shuffling logic.
  • Vectorized Bitwise Operations: Logical shifts, AND/OR/XOR, and blend operations are fully vectorized for 256-bit registers, accelerating cryptographic and bit-manipulation algorithms.
  • Extended Integer Arithmetic: Additional instructions for saturation arithmetic, byte/word multiplication, and horizontal additions provide finer control over fixed-point pipelines.

256-Bit Vectorized Convolution Implementation

The following implementation leverages AVX2 intrinsics to accelerate a 1D convolution with a fixed kernel size of 5. The algorithm broadcasts filter coefficients into vector registers, processes the input signal in blocks of four double-precision elements, and utilizes FMA instrucsions to accumulate results efficiently.

#include <immintrin.h>
#include <cstdint>

bool apply_conv1d_avx2(double* dst, const double* src, const double* taps, int64_t length) {
    constexpr int64_t K = 5;
    constexpr int64_t R = K / 2;

    if (length < K) return false;

    // Broadcast filter weights into 256-bit registers
    __m256d c0 = _mm256_set1_pd(taps[0]);
    __m256d c1 = _mm256_set1_pd(taps[1]);
    __m256d c2 = _mm256_set1_pd(taps[2]);
    __m256d c3 = _mm256_set1_pd(taps[3]);
    __m256d c4 = _mm256_set1_pd(taps[4]);

    // Vectorized core: stride of 4 doubles per iteration
    int64_t safe_bound = length - K + 1;
    for (int64_t pos = R; pos < safe_bound; pos += 4) {
        __m256d v0 = _mm256_loadu_pd(&src[pos + 2]);
        __m256d v1 = _mm256_loadu_pd(&src[pos + 1]);
        __m256d v2 = _mm256_loadu_pd(&src[pos]);
        __m256d v3 = _mm256_loadu_pd(&src[pos - 1]);
        __m256d v4 = _mm256_loadu_pd(&src[pos - 2]);

        __m256d acc = _mm256_setzero_pd();
        acc = _mm256_fmadd_pd(v0, c0, acc);
        acc = _mm256_fmadd_pd(v1, c1, acc);
        acc = _mm256_fmadd_pd(v2, c2, acc);
        acc = _mm256_fmadd_pd(v3, c3, acc);
        acc = _mm256_fmadd_pd(v4, c4, acc);

        _mm256_storeu_pd(&dst[pos], acc);
    }

    // Scalar fallback for right-side boundary elements
    for (int64_t pos = safe_bound; pos < length - R; ++pos) {
        double sum = 0.0;
        for (int64_t k = 0; k < K; ++k) {
            sum += src[pos - R + k] * taps[k];
        }
        dst[pos] = sum;
    }

    return true;
}

Scaling to 512-Bit Registers with AVX-512

Widening the execution path to AVX-512 doubles the number of double-precision elements processed per cycle. The structural logic remains identical, but register types, load/store intrinsics, and loop strides must be adjusted to accommodate 512-bit vectors. This version processes eight elements per iteration.

#include <immintrin.h>
#include <cstdint>

bool apply_conv1d_avx512(double* dst, const double* src, const double* taps, int64_t length) {
    constexpr int64_t K = 5;
    constexpr int64_t R = K / 2;

    if (length < K) return false;

    // Broadcast filter weights into 512-bit registers
    __m512d c0 = _mm512_set1_pd(taps[0]);
    __m512d c1 = _mm512_set1_pd(taps[1]);
    __m512d c2 = _mm512_set1_pd(taps[2]);
    __m512d c3 = _mm512_set1_pd(taps[3]);
    __m512d c4 = _mm512_set1_pd(taps[4]);

    // Vectorized core: stride of 8 doubles per iteration
    int64_t safe_bound = length - K + 1;
    for (int64_t pos = R; pos < safe_bound; pos += 8) {
        __m512d v0 = _mm512_loadu_pd(&src[pos + 2]);
        __m512d v1 = _mm512_loadu_pd(&src[pos + 1]);
        __m512d v2 = _mm512_loadu_pd(&src[pos]);
        __m512d v3 = _mm512_loadu_pd(&src[pos - 1]);
        __m512d v4 = _mm512_loadu_pd(&src[pos - 2]);

        __m512d acc = _mm512_setzero_pd();
        acc = _mm512_fmadd_pd(v0, c0, acc);
        acc = _mm512_fmadd_pd(v1, c1, acc);
        acc = _mm512_fmadd_pd(v2, c2, acc);
        acc = _mm512_fmadd_pd(v3, c3, acc);
        acc = _mm512_fmadd_pd(v4, c4, acc);

        _mm512_storeu_pd(&dst[pos], acc);
    }

    // Scalar fallback for remaining boundary elements
    for (int64_t pos = safe_bound; pos < length - R; ++pos) {
        double sum = 0.0;
        for (int64_t k = 0; k < K; ++k) {
            sum += src[pos - R + k] * taps[k];
        }
        dst[pos] = sum;
    }

    return true;
}

Performance Characteristics and Execution Notes

When benchmarking these implementations against a scalar baseline compiled with -O2, the AVX2 variant typically achieves a 3x to 4x speedup on modern x86 microarchitectures. The AVX-512 version can theoretically double that throughput, though actual gains depend heavily on memory bandwidth, cache hierarchy, and CPU frequency downclocking behavior when executing heavy 512-bit workloads. Utilizing FMA instructions reduces the critical path latency by merging multiplication and acccumulation, while unaligned loads (loadu) ensure correctness across arbitrary buffer alignments at a negligible performance cost on recent processors. For production deployments, combining these SIMD kernels with thread-level parallelism further saturates multi-core throughput.

Tags: AVX2 AVX512 SIMD C++ Intrinsics

Posted on Sat, 24 Oct 2026 16:42:43 +0000 by matthew798