Usando SSE

SSE é a sigla de Streaming SIMD Extensions – é uma sigla dentro de outra sigla… SIMD é Single Instruction Multiple Data. Em resumo, SSE é um jeito de realizar várias operações de uma só vez. Por exemplo, podemos fazer 4 multiplicações de floats com uma única instrução. Isso é muito útil quando queremos otimizar o código em muitos níveis de grandeza.

Os três principais compiladores C/C++ (GCC, Intel C++ Compiler e o compilador da Microsoft) permitem o uso de SSE através do que a intel chama de instruções intrínsecas. Ainda, se vocẽ se preocupa com compatibilidade (“E se o processador de meu cliente não suportar SSE?”) saiba que SSE está disponível desde o processador Pentium3 – e eu devo te lembrar que esse processador já tem 12 anos de idade!

Existem várias “versões” de SSE. Aqui tratarei apenas da primeira, como implementada no Pentium3. As outras são SSE2, SSE3, SSSE3 (com mais um ‘S’ mesmo!), SSE4.1, SSE4.2 e, se não estou enganado, os processadores i3, i5 e i7 já implementam SSE5. A diferença entre essas versões são recursos adicionais. SSE2, por exemplo, permite particionar os registradores do SSE em doubles. SSE3 permite particionar em inteiros (desde chars até longs). Além dessas melhorias, conjuntos extendidos de instruções são adicionados a cada versão. A versão 4.2, por exemplo, já implementa CRC32 e AES.

A seguir eis um exemplo de cálculo de produto vetorial, com e sem SSE. O código foi feito para o GCC, mas alterando o typedef vec4, você poderá compilá-lo no Visual Studio, por exemplo.

#include <stdint.h>
#include <stdio.h>
#include <xmmintrin.h>
#include "rdtsc.h"

typedef union {
  __m128 x;
  float v[4];
} vec4;

void cross_product(vec4 *out, vec4 *v1, vec4 *v2)
{
  out->v[0] = (v1->v[1] * v2->v[2]) - (v2->v[1] * v1->v[2]);
  out->v[1] = (v1->v[2] * v2->v[0]) - (v2->v[2] * v1->v[0]);
  out->v[2] = (v1->v[0] * v2->v[1]) - (v2->v[0] * v1->v[1]);
  out->v[3] = 1.0f;
}

void cross_product_sse(vec4 *out, vec4 *v1, vec4 *v2)
{
  __m128 a, b, c, d;

  a = _mm_shuffle_ps(v1->x, v1->x, _MM_SHUFFLE(3,0,2,1));
  b = _mm_shuffle_ps(v2->x, v2->x, _MM_SHUFFLE(3,1,0,2));
  c = _mm_shuffle_ps(v2->x, v2->x, _MM_SHUFFLE(3,0,2,1));
  d = _mm_shuffle_ps(v1->x, v1->x, _MM_SHUFFLE(3,1,0,2));

  b = _mm_mul_ps(a, b);
  a = _mm_mul_ps(c, d);

  out->x = _mm_sub_ps(b, a);
  out->v[3] = 1.0f;
}

int main(void)
{
  uint64_t t;
  vec4 v1 = { 3.0f, 2.0f, 1.0f, 1.0f };
  vec4 v2 = { 6.0f, 5.0f, 4.0f, 1.0f };
  vec4 r;

  BEGIN_RDTSC(t);
  cross_product(&r, &v1, &v2);
  END_RDTSC(t);
  printf("cross_product(): %llu cycles.\n", t);
  printf("Vector r: {%.4f, %.4f, %.4f, %.4f}.\n\n",
    r.v[0], r.v[1], r.v[2], r.v[3]);

  BEGIN_RDTSC(t);
  cross_product_sse(&r, &v1, &v2);
  END_RDTSC(t);
  printf("cross_product_sse(): %llu cycles.\n", t);
  printf("Vector r: {%.4f, %.4f, %.4f, %.4f}.\n\n",
    r.v[0], r.v[1], r.v[2], r.v[3]);

  return 0;
}

Você verá que a função cross_product_sse() é cerca de 16 vezes mais rápida que a função cross_product():

$ gcc -O0 -mtune=native -msse -mfpmath=both -fomit-frame-pointer \
  test.c rdtsc.c -o test
$ ./test
cross_product(): 2394 cycles.
Vector r: {3.0000, -6.0000, 3.0000, 1.0000}.

cross_product_sse(): 147 cycles.
Vector r: {3.0000, -6.0000, 3.0000, 1.0000}.

Para começarmos a entender o código tenho que existem 8 registradores para uso do SSE, cada um deles com 128 bits de tamanho:

Os registradores do SSE

No caso do SSE original esses registradores são subdivididos em 4 partições. Cada uma dessas partições corresponde a um float. O float menos significativo é o primeiro float do array, mostrado na função main(), na listagem acima. Para facilitar o entendimento do código, eis a organização de um único registrador… As partições estão numeradas de r0 a r3 para facilitar o entendimento da função _mm_shuffle_ps():

r# é uma partição de um único registrador.

Na função cross_product(), usamos o método tradicional de calcular o produto vetorial. É como calcular uma determinante. No caso da função cross_product_sse() fazemos a mesma coisa, mas precisamos preparar o terreno para realizar as 3 (na realidade, 4) multiplicações – de acordo com a diagonal – de uma só vez. Isso é feito via “embaralhamento”:

“Embaralhar”, em SSE, significa “trocar de posição”.

Note que o macro _MM_SHUFFLE indica como as partições serão embaralhadas. Os primeiros dois inteiros passados para o macro indicam quais partições do segundo parâmetro serão embaralhados. Os dois inteiros seguintes, as partições do primeiro parâmetro. Como usamos ambos os dois parâmetros iniciais como a mesma variável do tipo __m128, então os 4 parâmetros de _MM_SHUFFLE indicam as 4 partições da mesma variável.

Ao multiplicar os registradores embaralhados teremos o resultado da diagonal principal:

Obtenção da “diagonal principal” usando SSE.

Fazemos a mesma coisa com a outra diagonal e depois subtraímos os dois resultados. Precisamos setar a partição ‘w’ para 1.0f (só se formos usar coordenadas homogêneas) e temos o produto vetorial!

SSE exige alguns arranjos estruturais para garantir a performance. Um deles é o perfeito alinhamento dos dados, de 128 em 128 bits, para transferir dados da memória para os registradores xmm#. Por isso usei o atributo aligned(16) na união, embora, neste caso, o uso do atributo seja opcional, já que unir o array de floats com o tipo __m128 já garanta o alinhamento. De qualquer forma, se os dados não estiverem alinhados em 16 bytes (128 bits), o movimento dos dados será feito, com a penalidade de pelo menos 1 ciclo extra. Em assembly existem duas instruções diferentes para mover dados de e para a memóiria (MOVAPS e MOVUPS. A primeira move dados alinhados e a segunda dados desalinhados. No primeiro caso, se os dados não estiverem alinhados, pode ser gerada uma exceção – mas C/C++ toma conta de escolher qual das duas instruções será usada!).

Repare no atalho para inicialização das variáveis v1 e v2: O uso dos tipos e funções intrínsecas permite assinalar vetores de floats diretamente a uma variável do tipo __m128, onde o primeiro float corresponde à partição menos significativa de __m128.

SSE é uma forma de paralelismo. Realizar 4 operações de uma só vez (e sem usar threads) é algo que você pode querer se estiver lidando com computação gráfica ou, como demonstrado acima, cálculo vetorial. E isso está disponível já tem mais de uma década!

Se você ficou interessado nos tipos e funções intrínsecas do SSE nos compiladores C/C++. Dê uma olhada neste guia de referência da Intel. É a documentação do Intel C++ Compiler, mas aplica-se a todos os outros.

Anúncios

Um comentário sobre “Usando SSE

Deixe um comentário

Faça o login usando um destes métodos para comentar:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s