Pular para o conteúdo

Descobrindo SIMD: SSE2 e NEON

Esse post mostra o básico para criar códigos para SSE2 da Intel/AMD e NEON de processadores ARM em C/C++ usando o GCC/CLang e o Visual Studio.

Por muitos anos eu compilei projetos em C e C++ usando o Visual Studio em modo Release ou o GCC com flag -O3.

O compilador cria várias otimizações, mas é possível que não estejamos as instruções de dados paralelos das CPUs modernas.

A Intel e AMD implementaram um conjunto de instruções de CPU chamada de SSE (Streaming SIMD Extensions).

Considerando ARM, (a partir do armv7) existem instruções semelhantes as SSE2 chamadas NEON. Eu descobri essas porque eu comprei um Raspberry Pi Modelo B+ recentemente.

Mas o Que é o SSE na Prática?

As CPUs da Intel e AMD possuem registradores especiais de 128 bits que conseguem armazenar vários dados nesses registradores. Eles possuem instruções especiais para processar o dado neles.

Por exemplo: Podemos enviar uma instrução que faz quatro multiplicações e coloca o resultado em um único registrador. Ao invés de enviar quatro instruções de multiplicação (uma a uma).

Existem diversas instruções na especificação SSE que operam nesses registradores.

Mas o Que é o NEON na prática?

Alguns chips ARM possuem o NEON (SIMD avançado). A partir do arm-V7, cortex-A8, etc...

Existem algumas instruções que funcionam em 64 bits e outras que funcionam em 128 bits.

Assim como o SSE, podemos realizar 2 operações ou 4 operações utilizando somente uma instrução.

Observação Sobre o Layout da Memória

Não é possível usar o SSE ou NEON em qualquer dado na memória ou qualquer estrutura.

Precisamos que os endereços tanto da entrada quanto da saida estejam alinhados: no SSE é 16 bytes e no NEON é 8 bytes ou 16 bytes.

Para fazer todo o código compatível entre os sistemas eu coloquei todos os alinhamentos orientados a 16 bytes.

Os compiladores que eu tenho acesso são: clang (mac), gcc (linux, mac, windows) e Visual Studio (windows).

Cada sistema operacional e cada compilador tem suas próprias formas para configurar o alinhamento dos dados.

clang / gcc

Podemos usar o atributo __attribute__ (( __aligned__ (16))) no final da declaração de uma estrutura, classe ou vetor (array).

O GCC e o CLang são compiladores que deixam a compilação fácil, porque eles alinham os dados dentro do STL e do operador new.

Usando o atributo de alinhamento de dados em dados alinhados na pilha de execução (stack):

class MyClass {
public:
  float a;
  float b;
  float c;
  float d;
} __attribute__ (( __aligned__ (16)));

class MyClass {
public:
  char a;
  float b[4] __attribute__ (( __aligned__ (16)));
};

float data[4] __attribute__ (( __aligned__ (16)));

O GCC arranja automaticamente os dados na memória (heap) usando o operador new.

Para alocar blocos de memória alinhados, podemos usar ambos:
aligned_alloc / free
e
_mm_malloc / _mm_free .

float * alignedData = aligned_alloc( 16, sizeof(float) * n );
free( alignedData );

float * alignedData = _mm_malloc( sizeof(float) * n, 16 );
_mm_free( alignedData );

Visual Studio

Podemos usar a declaração __declspec(align(16)) no início da declaração dos dados.

Usando o atributo de alinhamento nos dados alinhados na pilha de execução (stack):

__declspec(align(16)) class MyClass {
public:
  float a;
  float b;
  float c;
  float d;
};

class MyClass {
public:
  char a;
  __declspec(align(16)) float b[4];
};

__declspec(align(16)) float data[4];

Essas declarações são usáveis somente da pilha de execução. No heap, precisamos de um cuidado especial, porque o Visual Studio não alinha os dados dos objetos usando o operador new.

O STL também não alinha dos dados.

Para fazer o operador new funcionar corretamente no Visual Studio, precisamos de implementar a sobrecarga do operador new e delete nas classes e estruturas:

__declspec(align(16)) class MyClass {
public:
    __declspec(align(16)) float data[4];
    // operator new
    void* operator new(size_t size) {
        return _mm_malloc(size, 16);
    }
    // operator delete
    void operator delete(void* p) { 
        _mm_free(p);
    }
    // operator new[]
    void* operator new[](size_t size) {
        return _mm_malloc(size, 16);
    }
    // operator delete[]
    void operator delete[](void* p) {
        _mm_free(p);
    }
    // placement operator new
    void* operator new (std::size_t n, void* ptr){
        return ptr;
    }
    // placement operator delete
    void operator delete(void *objectAllocated, void* ptr) {
    }
};

Aqui tem o operador new especial (placement new) e o operador delete especial (placement delete). Eles não são tão comuns, mas são usados dentro do STL no template vector por exemplo.

Para alocar um bloco de memória podemos usar: _mm_malloc / _mm_free .

float * alignedData = _mm_malloc( sizeof(float) * n, 16 );
_mm_free( alignedData );

E Sobre o STL em C++?

O STL funciona bem no GCC / CLANG, mas não funciona no Visual Studio.

Para se livrar de paradas repentinas no programa no Visual Studio por causa de dados desalinhados, precisamos criar um template allocator para as estruturas STL.

Eu testei o resultado usando o std::vector<> .

O allocator fala para o template STL como os dados podem ser alocados, liberados, inicializados e destruídos.

O exemplo do ssealign:

template <typename T, size_t N = 16>
class ssealign {
public:
  typedef T value_type;
  typedef size_t size_type;
  typedef ptrdiff_t difference_type;

  typedef T * pointer;
  typedef const T * const_pointer;

  typedef T & reference;
  typedef const T & const_reference;

public:
  inline ssealign() throw () { }

  template <typename T2>
  inline ssealign(const ssealign<T2, N> &) throw () { }

  inline ~ssealign() throw () { }

  inline pointer adress(reference r) {
    return &r;
  }

  inline const_pointer adress(const_reference r) const {
    return &r;
  }

  inline pointer allocate(size_type n) {
    return (pointer)_mm_malloc(n * sizeof(value_type),N);
  }

  inline void deallocate(pointer p, size_type) {
    _mm_free(p);
  }

  inline void construct(pointer p, const value_type & wert) {
    new (p) value_type(wert);
  }

  inline void destroy(pointer p) {
    p->~value_type();
  }

  inline size_type max_size() const throw () {
    return size_type(-1) / sizeof(value_type);
  }

  template <typename T2>
  struct rebind {
    typedef ssealign<T2, N> other;
  };

  bool operator!=(const ssealign<T, N>& other) const {
    return !(*this == other);
  }

  // Returns true if and only if storage allocated from *this
  // can be deallocated from other, and vice versa.
  // Always returns true for stateless allocators.
  bool operator==(const ssealign<T, N>& other) const {
    return true;
  }
};

Depois de definir esse allocator customizado, podemos usa-lo junto ao template std::vector<> na pilha de execução ou no armazenamento local.

Por exemplo:

// This declaration makes the vector template
// to align data to 16 bytes
std::vector<float, ssealign<float, 16> > floats;

//other example with MyClass (using the new operator overload)
std::vector<MyClass, ssealign<MyClass, 16> > objects;

Observações Sobre as Estruturas de Dados

Podemos criar um cabeçalho para conter a definição dos 2 tipos de declaração que vimos e usar o:

#ifdef _MSC_VER

Para checar se o código está sendo compilado no Visual Studio ou GCC/CLang.

Por exemplo:

#ifdef _MSC_VER
  #define _ALIGN_PRE __declspec(align(16))
  #define _ALIGN_POS
#else
  #define _ALIGN_PRE
  #define _ALIGN_POS __attribute__ (( __aligned__ (16)))
#endif

E a classe pode ser escrita dessa forma:

_ALIGN_PRE class MyClass { 
public:
  _ALIGN_PRE float data[4] _ALIGN_POS;

  // operator new and delete overload
  ...
} _ALIGN_POS;

Como Podemos Usar o SSE ou NEON em C/C++ ?

Depois de saber como definir os dados e como alinhá-los, podemos começar a programar nosso código usando SIMD.

Intel / AMD / SSE2

Eu escolhi o conjunto de instruções SSE2 para esse post porque ele pode ser usado tanto no hardware da Intel quanto no hardware da AMD.

Para usar o SSE2 você precisa configurar o compilador.

No Visual Studio é necessário configurar o flag de arquitetura para: /arch:SSE2 .

No GCC é necessário adicionar os flags de compilação: -mmmx -msse -msse2 -mfpmath=sse .

Primeiro precisamos incluir os arquivos:

#include <xmmintrin.h> // SSE1
#include <emmintrin.h> // SSE2

Depois disso podemos criar funções e métodos que usam os tipos internos.

No SSE podemos usar o tipo __m128 (ele contem  4 floats ou 4 ints[32bits] ou 8 ints[16bits], etc...).

Todas as funções SSE começam com _mm_*. Então se queremos somar 4 floats, podemos usar o _mm_add_ps().

Exemplo:

// to avoid typecasting float to __m128, 
// we can use the union
_ALIGN_PRE struct vec4 {
  union{
    struct{ float x,y,z,w; };
    __m128 sse_data;
  };
}_ALIGN_POS;

// define variables
vec4 a,b,c;

// add instruction : C = A + B
c.sse_data = _mm_add_ps( a.sse_data, b.sse_data );

ARM / NEON

Eu escolhi as instruções NEON para esse post porque elas podem ser usadas no Raspberry Pi Modelo B+ que eu comprei recentemente. Mas eu vi que essas instruções estão disponíveis também em vários hardware Android e iOS.

Para ser possível usar as instruções NEON, é necessário configurar o compilador.

No GCC você precisar adicionar a flag de compilação: -mfpu=neon .

No NEON você precisa incluir:

#include <arm_neon.h>

Depois disso podemos criar funções e métodos que usam os tipos internos.

No NEON podemos usar o tipo float32x4_t (ele contem 4 floats). Existem outros tipos.

As funções no NEON começãom com v*. Então se queremos adicionar 4 floats, podemos usar o vaddq_f32().

// to avoid typecasting float to float32x4_t, 
// we can use the union
_ALIGN_PRE struct vec4 {
  union{
    struct{ float x,y,z,w; };
    float32x4_t neon_data;
  };
}_ALIGN_POS;

// define variables
vec4 a,b,c;

// add instruction : C = A + B
c.neon_data = vaddq_f32( a.neon_data, b.neon_data );

Próximos Passos?

Eu penso que isso é o começo.

Você pode dar uma olhada na documentação do SSE2 ou NEON para ver quais instruções eles tem e tentar criar o algoritmo SIMD que você precisa.

SSE2 Intrinsics Listing

Neon Intrinsics Listing

Versão SIMD de Operações Vetoriais Para Aplicações 3D

Eu portei o código de matemática vetorial da minha biblioteca para usar ambos: SSE2 e NEON. Ela contem a estrutura e operações com vetores de 2, 3 e 4 componentes, quaternions e matrizes 4x4

Você pode conferir o código no GitHUB.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *