Visão geral do C++ AMP

Observação

Os cabeçalhos AMP C++ foram preteridos a partir do Visual Studio 2022 versão 17.0. Incluir todos os cabeçalhos AMP gerará erros de build. Defina _SILENCE_AMP_DEPRECATION_WARNINGS antes de incluir qualquer cabeçalho AMP para silenciar os avisos.

O C++ AMP (C++ Accelerated Massive Parallelism) acelera a execução do código C++ aproveitando o hardware paralelo de dados, como uma GPU (unidade de processamento gráfico) em uma placa gráfica discreta. Usando C++ AMP, você pode codificar algoritmos de dados multidimensionais para que a execução possa ser acelerada usando paralelismo em hardware heterogêneo. O modelo de programação do C++ AMP inclui matrizes multidimensionais, indexação, transferência de memória, agrupamento lado a lado e uma biblioteca de funções matemáticas. Você pode usar extensões de linguagem C++ AMP para controlar como os dados são movidos da CPU para a GPU e de volta, para que você possa melhorar o desempenho.

Requisitos do sistema

  • Windows 7 ou posterior

  • Windows Server 2008 R2 até o Visual Studio 2019.

  • Hardware DirectX 11 com nível de recurso 11.0 ou posterior

  • Para depuração no emulador de software, é necessário Windows 8 ou Windows Server 2012. Para depurar no hardware, você deve instalar os drivers para a sua placa gráfica. Para obter mais informações, confira Depurando Código GPU.

  • Observação: no momento, não há suporte para AMP no ARM64.

Introdução

Os dois exemplos a seguir ilustram os componentes principais do C++ AMP. Suponha que você queira adicionar os elementos correspondentes de duas matrizes unidimensionais. Por exemplo, você pode adicionar {1, 2, 3, 4, 5} e {6, 7, 8, 9, 10} para obter {7, 9, 11, 13, 15}. Sem usar C++ AMP, você pode escrever o código a seguir para adicionar os números e exibir os resultados.

#include <iostream>

void StandardMethod() {

    int aCPP[] = {1, 2, 3, 4, 5};
    int bCPP[] = {6, 7, 8, 9, 10};
    int sumCPP[5];

    for (int idx = 0; idx < 5; idx++)
    {
        sumCPP[idx] = aCPP[idx] + bCPP[idx];
    }

    for (int idx = 0; idx < 5; idx++)
    {
        std::cout << sumCPP[idx] << "\n";
    }
}

As partes importantes do código são as seguintes:

  • Dados: os dados consistem em três matrizes. Todos têm a mesma classificação (um) e comprimento (cinco).

  • Iteração: o primeiro loop for fornece um mecanismo para iterar pelos elementos nas matrizes. O código que você deseja executar para computar as somas está contido no primeiro bloco for.

  • Índice: A variável idx acessa os elementos individuais das matrizes.

Usando C++ AMP, você pode escrever o código a seguir.

#include <amp.h>
#include <iostream>
using namespace concurrency;

const int size = 5;

void CppAmpMethod() {
    int aCPP[] = {1, 2, 3, 4, 5};
    int bCPP[] = {6, 7, 8, 9, 10};
    int sumCPP[size];

    // Create C++ AMP objects.
    array_view<const int, 1> a(size, aCPP);
    array_view<const int, 1> b(size, bCPP);
    array_view<int, 1> sum(size, sumCPP);
    sum.discard_data();

    parallel_for_each(
        // Define the compute domain, which is the set of threads that are created.
        sum.extent,
        // Define the code to run on each thread on the accelerator.
        [=](index<1> idx) restrict(amp) {
            sum[idx] = a[idx] + b[idx];
        }
    );

    // Print the results. The expected output is "7, 9, 11, 13, 15".
    for (int i = 0; i < size; i++) {
        std::cout << sum[i] << "\n";
    }
}

Os mesmos elementos básicos estão presentes, mas constructos C++ AMP são usados:

  • Dados: você usa matrizes C++ para construir três objetos array_view C++ AMP. Você fornece quatro valores para construir um objeto array_view: os valores de dados, a classificação, o tipo de elemento e o comprimento do objeto array_view em cada dimensão. A classificação e o tipo são passados como parâmetros de tipo. Os dados e o comprimento são passados como parâmetros de construtor. Neste exemplo, a matriz C++ que é passada para o construtor é unidimensional. A classificação e o comprimento são usados para construir a forma retangular dos dados no objeto array_view e os valores de dados são usados para preencher a matriz. A biblioteca de runtime também inclui a Classe array, que tem uma interface que se assemelha à classe array_view e é discutida posteriormente neste artigo.

  • Iteração: a Função parallel_for_each (C++ AMP) fornece um mecanismo para iterar pelos elementos de dados ou domínio de computação. Neste exemplo, o domínio de computação é especificado por sum.extent. O código que você deseja executar está contido em uma expressão lambda ou função de kernel. O restrict(amp) indica que apenas o subconjunto da linguagem C++ que o C++ AMP pode acelerar.

  • Índice: a variável Classe index, idx, é declarada com uma classificação de um para corresponder à classificação do objeto array_view. Usando o índice, você pode acessar os elementos individuais dos objetos array_view.

Modelagem e indexação de dados: índice e extensão

Defina os valores de dados e declarar a forma dos dados para executar o código do kernel. Todos os dados são definidos como uma matriz (retangular) e você pode definir a matriz para ter qualquer classificação (número de dimensões). Os dados podem ser de qualquer tamanho em qualquer uma das dimensões.

Classe index

A Classe index especifica um local no objeto array ou array_view encapsulando o deslocamento da origem em cada dimensão em um objeto. Quando você acessa um local na matriz, passa um objeto index para o operador de indexação, [], em vez de uma lista de índices inteiros. Você pode acessar os elementos em cada dimensão usando o Operador array::operator() ou o Operador array_view::operator().

O exemplo a seguir cria um índice unidimensional que especifica o terceiro elemento em um objeto unidimensional array_view. O índice é usado para imprimir o terceiro elemento no objeto array_view. A saída é 3.

int aCPP[] = {1, 2, 3, 4, 5};
array_view<int, 1> a(5, aCPP);

index<1> idx(2);

std::cout << a[idx] << "\n";
// Output: 3

O exemplo a seguir cria um índice bidimensional que especifica o elemento em que a linha = 1 e a coluna = 2 em um objeto bidimensional array_view. O primeiro parâmetro no construtor index é o componente de linha e o segundo parâmetro é o componente de coluna. A saída é 6.

int aCPP[] = {1, 2, 3, 4, 5, 6};
array_view<int, 2> a(2, 3, aCPP);

index<2> idx(1, 2);

std::cout <<a[idx] << "\n";
// Output: 6

O exemplo a seguir cria um índice tridimensional que especifica o elemento em que profundidade = 0, linha = 1 e coluna = 3 em um objeto tridimensional array_view. Observe que o primeiro parâmetro é o componente de profundidade, o segundo parâmetro é o componente de linha e o terceiro parâmetro é o componente de coluna. A saída é 8.

int aCPP[] = {
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};

array_view<int, 3> a(2, 3, 4, aCPP);

// Specifies the element at 3, 1, 0.
index<3> idx(0, 1, 3);

std::cout << a[idx] << "\n";
// Output: 8

Classe extent

A Classe extent especifica o comprimento dos dados em cada dimensão do objeto array ou array_view. Você pode criar uma extensão e usá-la para criar um objeto array ou array_view. Você também pode recuperar a extensão de um objeto array ou array_view existente. O exemplo a seguir imprime o comprimento da extensão em cada dimensão de um objeto array_view.

int aCPP[] = {
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
// There are 3 rows and 4 columns, and the depth is two.
array_view<int, 3> a(2, 3, 4, aCPP);

std::cout << "The number of columns is " << a.extent[2] << "\n";
std::cout << "The number of rows is " << a.extent[1] << "\n";
std::cout << "The depth is " << a.extent[0] << "\n";
std::cout << "Length in most significant dimension is " << a.extent[0] << "\n";

O exemplo a seguir cria um objeto array_view que tem as mesmas dimensões que o objeto no exemplo anterior, mas este exemplo usa um objeto extent, em vez de usar parâmetros explícitos no construtor array_view.

int aCPP[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24};
extent<3> e(2, 3, 4);

array_view<int, 3> a(e, aCPP);

std::cout << "The number of columns is " << a.extent[2] << "\n";
std::cout << "The number of rows is " << a.extent[1] << "\n";
std::cout << "The depth is " << a.extent[0] << "\n";

Como mover dados para o acelerador: array e array_view

Dois contêineres de dados usados para mover dados para o acelerador são definidos na biblioteca de runtime. Eles são a Classe array e a classe array_view. A classe array é uma classe de contêiner que cria uma cópia profunda dos dados quando o objeto é construído. A classe array_view é uma classe wrapper que copia os dados quando a função de kernel acessa os dados. Quando os dados são necessários no dispositivo de origem, eles são copiados novamente.

Classe array

Quando um objeto array é construído, uma cópia profunda dos dados será criada no acelerador se você usar um construtor que inclui um ponteiro para o conjunto de dados. A função de kernel modifica a cópia no acelerador. Quando a execução da função de kernel for concluída, você deverá copiar os dados de volta para a estrutura de dados de origem. O exemplo a seguir multiplica cada elemento em um vetor por 10. Depois que a função de kernel for concluída, vector conversion operator será usado para copiar os dados de volta para o objeto vetor.

std::vector<int> data(5);

for (int count = 0; count <5; count++)
{
    data[count] = count;
}

array<int, 1> a(5, data.begin(), data.end());

parallel_for_each(
    a.extent,
    [=, &a](index<1> idx) restrict(amp) {
        a[idx] = a[idx]* 10;
    });

data = a;
for (int i = 0; i < 5; i++)
{
    std::cout << data[i] << "\n";
}

Classe array_view

A array_view tem quase os mesmos membros da classe array, mas o comportamento subjacente não é o mesmo. Os dados passados para o construtor array_view não são replicados na GPU como acontece com um construtor array. Em vez disso, os dados são copiados para o acelerador quando a função de kernel é executada. Portanto, se você criar dois objetos array_view que usam os mesmos dados, ambos os objetos array_view se referirão ao mesmo espaço de memória. Ao fazer isso, você precisa sincronizar qualquer acesso multithread. A principal vantagem de usar a classe é que os dados array_view são movidos somente se necessário.

Comparação de matriz e array_view

A tabela a seguir resume as semelhanças e as diferenças entre as classes array e array_view.

Descrição Classe Array Classe array_view
Quando a classificação é determinada Em tempo de compilação. Em tempo de compilação.
Quando a extensão é determinada Em tempo de execução. Em tempo de execução.
Forma Retangular. Retangular.
Armazenamento de dados É um contêiner de dados. É um wrapper de dados.
Copiar Cópia explícita e profunda na definição. Cópia implícita quando ela é acessada pela função de kernel.
Recuperação de dados Copiando os dados da matriz de volta para um objeto no thread da CPU. Por acesso direto ao objeto array_view ou chamando o método array_view::synchronize para continuar acessando os dados no contêiner original.

Memória compartilhada com array e array_view

Memória compartilhada é memória que pode ser acessada pela CPU e pelo acelerador. O uso de memória compartilhada elimina ou reduz significativamente a sobrecarga de cópia de dados entre a CPU e o acelerador. Embora a memória seja compartilhada, ela não pode ser acessada simultaneamente pela CPU e pelo acelerador e fazer isso causa um comportamento indefinido.

Os objetos array podem ser usados para especificar o controle refinado sobre o uso de memória compartilhada se o acelerador associado der suporte a ele. Se um acelerador oferece suporte à memória compartilhada é determinado pela propriedade supports_cpu_shared_memory do acelerador, que retorna true quando a memória compartilhada é suportada. Se houver suporte para memória compartilhada, a Enumeração de access_type padrão para alocações de memória no acelerador será determinada pela propriedade default_cpu_access_type. Por padrão, objetos array e array_view assumem o mesmo access_type que o primário accelerator associado.

Ao definir a propriedade array::cpu_access_type Data Member de um explicitamente, você pode exercer um array controle refinado sobre como a memória compartilhada é usada, para que você possa otimizar o aplicativo para as características de desempenho do hardware, com base nos padrões de acesso à memória de seus kernels de computação. Um array_view reflete o mesmo cpu_access_type que o que está associado ou, se o array_view é construído sem uma fonte de dados, reflete access_type o array ambiente que primeiro faz com que ele aloque armazenamento. Ou seja, se ele é acessado primeiro pelo host (CPU), então ele se comporta como se fosse criado sobre uma fonte de dados da CPU e compartilha o access_typeaccelerator_view do associado por captura, no entanto, se ele é acessado primeiro por um , então ele se comporta como se fosse criado sobre um arrayaccelerator_viewcriado sobre isso accelerator_view e compartilha o .access_typearray

O exemplo de código a seguir mostra como determinar se o acelerador padrão dá suporte à memória compartilhada e então cria várias matrizes que têm configurações de cpu_access_type diferentes.

#include <amp.h>
#include <iostream>

using namespace Concurrency;

int main()
{
    accelerator acc = accelerator(accelerator::default_accelerator);

    // Early out if the default accelerator doesn't support shared memory.
    if (!acc.supports_cpu_shared_memory)
    {
        std::cout << "The default accelerator does not support shared memory" << std::endl;
        return 1;
    }

    // Override the default CPU access type.
    acc.default_cpu_access_type = access_type_read_write

    // Create an accelerator_view from the default accelerator. The
    // accelerator_view inherits its default_cpu_access_type from acc.
    accelerator_view acc_v = acc.default_view;

    // Create an extent object to size the arrays.
    extent<1> ex(10);

    // Input array that can be written on the CPU.
    array<int, 1> arr_w(ex, acc_v, access_type_write);

    // Output array that can be read on the CPU.
    array<int, 1> arr_r(ex, acc_v, access_type_read);

    // Read-write array that can be both written to and read from on the CPU.
    array<int, 1> arr_rw(ex, acc_v, access_type_read_write);
}

Como executar código sobre dados: parallel_for_each

A função parallel_for_each define o código que você deseja executar no acelerador em relação aos dados no objeto array ou array_view. Considere o código a seguir da introdução deste tópico.

#include <amp.h>
#include <iostream>
using namespace concurrency;

void AddArrays() {
    int aCPP[] = {1, 2, 3, 4, 5};
    int bCPP[] = {6, 7, 8, 9, 10};
    int sumCPP[5] = {0, 0, 0, 0, 0};

    array_view<int, 1> a(5, aCPP);
    array_view<int, 1> b(5, bCPP);
    array_view<int, 1> sum(5, sumCPP);

    parallel_for_each(
        sum.extent,
        [=](index<1> idx) restrict(amp)
        {
            sum[idx] = a[idx] + b[idx];
        }
    );

    for (int i = 0; i < 5; i++) {
        std::cout << sum[i] << "\n";
    }
}

O método parallel_for_each usa dois argumentos, um domínio de computação e uma expressão lambda.

O domínio de computação é um objeto extent ou um objeto tiled_extent que define o conjunto de threads a ser criado para execução paralela. Um thread é gerado para cada elemento no domínio de computação. Nesse caso, o objeto extent é unidimensional e tem cinco elementos. Portanto, cinco threads são iniciados.

A expressão lambda define o código a ser executado em cada thread. A cláusula de captura, [=], especifica que o corpo da expressão lambda acessa todas as variáveis capturadas por valor, que, nesse caso, são a, b e sum. Neste exemplo, a lista de parâmetros cria uma variável unidimensional index chamada idx. O valor do idx[0] é 0 no primeiro thread e aumenta em um em cada thread subsequente. O restrict(amp) indica que apenas o subconjunto da linguagem C++ que o C++ AMP pode acelerar. As limitações nas funções que têm o modificador de restrição são descritas em restrição (C++ AMP). Para mais informações, confira Sintaxe de expressão lambda.

A expressão lambda pode incluir o código a ser executado ou pode chamar uma função de kernel separada. A função de kernel deve incluir o modificador restrict(amp). O exemplo a seguir é equivalente ao exemplo anterior, mas chama uma função de kernel separada.

#include <amp.h>
#include <iostream>
using namespace concurrency;

void AddElements(
    index<1> idx,
    array_view<int, 1> sum,
    array_view<int, 1> a,
    array_view<int, 1> b) restrict(amp) {
    sum[idx] = a[idx] + b[idx];
}

void AddArraysWithFunction() {

    int aCPP[] = {1, 2, 3, 4, 5};
    int bCPP[] = {6, 7, 8, 9, 10};
    int sumCPP[5] = {0, 0, 0, 0, 0};

    array_view<int, 1> a(5, aCPP);
    array_view<int, 1> b(5, bCPP);
    array_view<int, 1> sum(5, sumCPP);

    parallel_for_each(
        sum.extent,
        [=](index<1> idx) restrict(amp) {
            AddElements(idx, sum, a, b);
        }
    );

    for (int i = 0; i < 5; i++) {
        std::cout << sum[i] << "\n";
    }
}

Como acelerar o código: blocos e barreiras

Você pode obter aceleração adicional usando o bloco. O bloco divide os threads em subconjuntos retangulares iguais ou blocos. Você determina o tamanho do bloco apropriado com base no conjunto de dados e no algoritmo que você está codificando. Para cada thread, você tem acesso à localização global de um elemento de dados relativo a todo array ou array_view e acesso à localização local em relação ao bloco. Usar o valor do índice local simplifica seu código porque você não precisa escrever o código para converter valores de índice de global em local. Para usar o bloco, chame o Método extent::tile no domínio de computação no método parallel_for_each e use um objeto tiled_index na expressão lambda.

Em aplicativos típicos, os elementos em um bloco estão relacionados de alguma forma, e o código precisa acessar e controlar os valores em todo o bloco. Use a palavra-chave Palavra-chave tile_static e o Método tile_barrier::wait para fazer isso. Uma variável que tem a palavra-chave tile_static tem um escopo em um bloco inteiro e uma instância da variável é criada para cada bloco. Você deve lidar com a sincronização do acesso de bloco-thread à variável. O Método tile_barrier::wait interrompe a execução do thread atual até que todos os threads no bloco tenham atingido a chamada para tile_barrier::wait. Portanto, você pode acumular valores no bloco usando variáveis tile_static. Em seguida, você pode concluir todos os cálculos que exijam acesso a todos os valores.

O diagrama a seguir representa uma matriz bidimensional de dados de amostragem organizados em blocos.

Index values in a tiled extent.

O exemplo de código a seguir usa os dados de amostragem do diagrama anterior. O código substitui cada valor no bloco pela média dos valores no bloco.

// Sample data:
int sampledata[] = {
    2, 2, 9, 7, 1, 4,
    4, 4, 8, 8, 3, 4,
    1, 5, 1, 2, 5, 2,
    6, 8, 3, 2, 7, 2};

// The tiles:
// 2 2    9 7    1 4
// 4 4    8 8    3 4
//
// 1 5    1 2    5 2
// 6 8    3 2    7 2

// Averages:
int averagedata[] = {
    0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0,
};

array_view<int, 2> sample(4, 6, sampledata);

array_view<int, 2> average(4, 6, averagedata);

parallel_for_each(
    // Create threads for sample.extent and divide the extent into 2 x 2 tiles.
    sample.extent.tile<2,2>(),
        [=](tiled_index<2,2> idx) restrict(amp) {
        // Create a 2 x 2 array to hold the values in this tile.
        tile_static int nums[2][2];

        // Copy the values for the tile into the 2 x 2 array.
        nums[idx.local[1]][idx.local[0]] = sample[idx.global];

        // When all the threads have executed and the 2 x 2 array is complete, find the average.
        idx.barrier.wait();
        int sum = nums[0][0] + nums[0][1] + nums[1][0] + nums[1][1];

        // Copy the average into the array_view.
        average[idx.global] = sum / 4;
    });

for (int i = 0; i <4; i++) {
    for (int j = 0; j <6; j++) {
        std::cout << average(i,j) << " ";
    }
    std::cout << "\n";
}

// Output:
// 3 3 8 8 3 3
// 3 3 8 8 3 3
// 5 5 2 2 4 4
// 5 5 2 2 4 4

Bibliotecas matemáticas

C++ AMP inclui duas bibliotecas matemáticas. A biblioteca de precisão dupla no Namespace Concurrency::precise_math dá suporte para funções de precisão dupla. Ele também dá suporte para funções de precisão simples, embora ainda seja necessário suporte de precisão dupla no hardware. Ele está em conformidade com a Especificação C99 (ISO/IEC 9899). O acelerador deve dar suporte a precisão dupla completa. Você pode determinar se ele faz isso verificando o valor do Membro de Dados accelerator::supports_double_precision. A biblioteca de matemática rápida, no Namespace Concurrency::fast_math, contém outro conjunto de funções matemáticas. Essas funções, que suportam apenas float operandos, são executadas mais rapidamente, mas não são tão precisas quanto as da biblioteca matemática de precisão dupla. As funções estão contidas no <arquivo de cabeçalho amp_math.h> e todas são declaradas com restrict(amp). As funções no arquivo de cabeçalho <cmath> são importadas para os namespaces fast_math e precise_math. A palavra-chave restrict é usada para distinguir a versão <cmath> e a versão C++ AMP. O código a seguir calcula o logaritmo base-10, usando o método rápido, de cada valor que está no domínio de computação.

#include <amp.h>
#include <amp_math.h>
#include <iostream>
using namespace concurrency;

void MathExample() {

    double numbers[] = { 1.0, 10.0, 60.0, 100.0, 600.0, 1000.0 };
    array_view<double, 1> logs(6, numbers);

    parallel_for_each(
        logs.extent,
        [=] (index<1> idx) restrict(amp) {
            logs[idx] = concurrency::fast_math::log10(numbers[idx]);
        }
    );

    for (int i = 0; i < 6; i++) {
        std::cout << logs[i] << "\n";
    }
}

Biblioteca de elementos gráficos

C++ AMP inclui uma biblioteca de elementos gráficos projetada para programação gráfica acelerada. Essa biblioteca é usada apenas em dispositivos que dão suporte à funcionalidade de elementos gráficos nativos. Os métodos estão no Namespace Concurrency::graphics e contidos no arquivo de cabeçalho <amp_graphics.h>. Os principais componentes da biblioteca de elementos gráficos são:

  • Classe de textura: você pode usar a classe de textura para criar texturas de memória ou de um arquivo. As texturas se assemelham a matrizes porque contêm dados e se assemelham a contêineres na Biblioteca Padrão do C++ em relação à atribuição e à construção de cópia. Para obter mais informações, consulte Contêineres da biblioteca padrão C++. Os parâmetros de modelo para a classe texture são o tipo de elemento e a classificação. A classificação pode ser 1, 2 ou 3. O tipo de elemento pode ser um dos tipos de vetor curtos descritos posteriormente neste artigo.

  • classe writeonly_texture_view: fornece acesso somente gravação a qualquer textura.

  • Biblioteca vetorial curta: define um conjunto de tipos de vetores curtos de comprimento 2, 3 e 4 que se baseiam em int, uint, float, double, norm ou unorm.

Aplicativos da UWP (Plataforma Universal do Windows)

Como outras bibliotecas C++, você pode usar C++ AMP em seus aplicativos UWP. Estes artigos descrevem como incluir código C++ AMP em aplicativos criados usando C++, C#, Visual Basic ou JavaScript:

Visualizador de simultaneidade e C++ AMP

O Visualizador de Simultaneidade inclui suporte para analisar o desempenho de código C++ AMP. Estes artigos descrevem estes recursos:

Recomendações de desempenho

O módulo e a divisão de inteiros sem sinal têm um desempenho significativamente melhor do que o módulo e a divisão de inteiros assinados. Recomendamos que você use inteiros sem sinal quando possível.

Confira também

C++ AMP (C++ Accelerated Massive Parallelism)
Sintaxe da expressão lambda
Referência (C++ AMP)
Programação paralela no blog de código nativo