• Nenhum resultado encontrado

PRÓ-REITORIA DE EXTENSÃO, PESQUISA E INOVAÇÃO DIRETORIA DE PESQUISA E INOVAÇÃO PROPOSTA DE PROJETO DE PESQUISA

N/A
N/A
Protected

Academic year: 2021

Share "PRÓ-REITORIA DE EXTENSÃO, PESQUISA E INOVAÇÃO DIRETORIA DE PESQUISA E INOVAÇÃO PROPOSTA DE PROJETO DE PESQUISA"

Copied!
49
0
0

Texto

(1)

PRÓ-REITORIA DE EXTENSÃO, PESQUISA E INOVAÇÃO DIRETORIA DE PESQUISA E INOVAÇÃO

PROPOSTA DE PROJETO DE PESQUISA

1. IDENTIFICAÇÃO DO PROJETO

1.1. Título: Contribuições ao teste de cobertura de aplicações CUDA

1.2. Grande Área do Conhecimento:

( X ) 1. Exatas e da Terra ( ) 2. Biológicas ( ) 3. Engenharias

( ) 4. Saúde ( ) 5. Agrárias ( ) 6. Sociais Aplicadas

( ) 7. Linguística, Letras e Artes ( ) 8. Humanas ( ) 9. Outros

1.3. Área do Conhecimento:

Nome: Ciência da Computação Código: 1.03.00.00-7

1.4. Sub-Área do Conhecimento:

Nome: Teoria da Computação Código: 1.03.01.00-3

1.5. Este projeto necessita avaliação por um Comitê de Ética? ( ) Sim (X) Não

1.5.1. Caso a proposta não necessite de avaliação por um Comitê de Ética por se enquadrar em algum dos casos descritos na Resolução 510/2016 do Conselho Nacional de Saúde, descreva-os:

1.6. Este projeto foi aprovado por um Comitê de Ética?

( ) Sim ( X ) Não ( ) Em Análise pelo Comitê ________________________

2. INTEGRANTES DA PROPOSTA 2.1. Coordenador:

Nome: Helder Jefferson Ferreira da Luz

CPF: 388.974.048-04

Campus: União da Vitória

Titulação (Graduação): Bacharel em Ciência da computação

Titulação (Pós-graduação): Mestre em Ciências de Computação e

Matemática Computacional Carga horária dedicada ao Projeto: 12

E-mail: helder.luz@ifpr.edu.br

(2)

3. CARACTERIZAÇÃO DA PROPOSTA 3.1. Resumo do Projeto:

Os processadores gráficos se apresentam como uma solução acessível para computação de alto desempenho, sendo utilizada na resolução de problemas de diversas áreas. A utilização desses processadores envolve novos modelos de programação, como o CUDA, que buscam facilitar o desenvolvimento de aplicações de propósito geral para a execução em processadores gráficos. A despeito das facilidades trazidas por esse modelo, desenvolver aplicações com CUDA não é trivial. Além disso os desenvolvedores possuem pouca experiência na programação de aplicações paralelas, ocasionando diversos tipos de defeitos. Apesar da necessidade de mitigar defeitos em aplicações CUDA, as ferramentas de teste atuais não oferecem o suporte necessário, se limitando a poucos tipos de defeitos que tais aplicações podem apresentar. Buscando melhorar a qualidade de programas CUDA, propõe-se o desenvolvimento de um modelo e de critérios de teste estrutural para programas concorrentes em CUDA. A seguir ambos serão validados por meio de um protótipo de ferramenta que implemente tal modelo e critérios. Espera-se como contribuições uma taxonomia de defeitos, benchmarks em CUDA que contenham defeitos dessa taxonomia, modelo e critérios de teste estrutural, protótipo que implemente o modelo e critérios, resultando em uma melhora na qualidade de programas CUDA e na utilização do material para ensino de Teste de Software e programação com CUDA.

3.2. Palavras–chave:

Programação Concorrente, Computação de Alto Desempenho, Teste de Programas Concorrentes, GPU, CUDA.

3.3. Referencial Teórico/Fundamentação da Proposta:

3.3.1 Introdução

A computação paralela vem sendo cada vez mais utilizada (GRAMA et al., 2003; PATTERSON; HENNESSY, 2013). A disponibilidade da computação de alto desempenho (High Performance Computing - HPC) é estimulada por uma constante evolução e barateamento do hardware, onde verifica-se facilmente a existência de clusters beowulf (STERLING et al., 1995), processadores com múltiplos núcleos \cite{patterson:2013:computer} e processadores específicos para o processamento de vídeo (Graphics Processing Unit - GPU) (PATTERSON; HENNESSY, 2013), rede (Network Processing Unit - NPU) (GILADI, 2008),, entre outros (Top500.org, 2015). Uma característica marcante desse hardware paralelo é a presença de duas categorias de máquinas: as MIMD (Multiple Instruction; Multiple Data) e as SIMD (Single Instruction; Multiple Data). Clusters e processadores com múltiplos núcleos são bons exemplos dessas máquinas MIMD, respectivamente com memória

(3)

distribuída e compartilhada. Processadores específicos, como as GPUs, são exemplos de máquinas SIMD.

Alguns exemplos onde a HPC é aplicada diretamente são: tratamento de big data,

dinâmica molecular, processamento de imagens, previsão climática – incluindo

terremotos e maremotos.

Do ponto de vista de software, os sistemas operacionais e modelos de programação estão evoluindo, na tentativa de oferecer suporte adequado para esses novos recursos de hardware. Nesse sentido são comumente utilizados sistemas operacionais de rede, como muitas distribuições Linux (TANENBAUM; BOS, 2014); e modelos de programação concorrente com memória distribuída ou memória compartilhada (GRAMA et al., 2003). O padrão MPI (Message Passing Interface) (MPI Forum, 2015) é usualmente encontrado para a HPC com memória distribuída. Já o OpenMP (Open Multi-Processing) (OpenMP ARB, 2015) e PThreads (POSIX Threads) (IEEE, 2008) são facilmente encontrados na tentativa de viabilizar a HPC com memória compartilhada. Outros modelos de programação com uma crescente utilização são CUDA (Compute Unified Device Architecture) (NVIDIA, 2015) e OpenCL (Open Computing Language) (Khronos Group, 2015), os quais viabilizam o uso de GPUs para o processamento de imagens e de outras computações genéricas, normalmente envoltas com problemas matriciais.

O OpenCL, inicialmente proposto pela Apple, é gerenciado pela Khronos The Compute Working Group (Khronos Group, 2015). Khronos é um consórcio de empresas como AMD (Advanced Micro Devices), Nvidia e ARM (Advanced RISC Machine) para a criação de padrões abertos para a programação de sistemas heterogêneos. OpenCL é um framework para o desenvolvimento de programas concorrentes portáteis, que possam ser executados em diversas plataformas, como diversas arquiteturas de CPUs (Central Processing Unit), GPUs, DSPs (Digital Signal Processing), dentre outras (Khronos Group, 2015).

CUDA, desenvolvido pela Nvidia, é um modelo de programação e plataforma de computação paralela para propósitos gerais voltado para GPUs da Nvidia. Ele facilita a implementação e solução de problemas computacionais complexos em GPU (CHENG; GROSSMAN; MCKERCHER, 2014). Ele possui bibliotecas para facilitar o uso eficiente da arquitetura. Suas bibliotecas podem ser utilizadas em diversas linguagens de programação como C, Python e Fortran. Apesar do seu uso restrito a uma marca, a Nvidia domina hoje o mercado de placas gráficas, tendo alcançado

(4)

uma fatia de mercado de 82\% no segundo trimestre de 2015 de acordo com a Jon

Peddie Research (2015). CUDA também apresenta um desempenho

substancialmente maior que o OpenCL (FANG;VARBANESCU; SIPS, 2011) na maioria das aplicações e benchmarks encontrados no mercado. Devido a isso, neste momento, CUDA se apresenta como o principal modelo de programação para GPU utilizado para HPC e é considerado um padrão de fato na área e será utilizado neste projeto.

Os dois principais apelos para o uso de CUDA são: (i) a possibilidade de se obter ganhos significativos de desempenho; (ii) a possibilidade de desenvolvimento de código concorrente muito próximo ao sequencial, abstraindo-se vários dos detalhes da geração de processos/threads e da IPC (Interprocess Communication). Os ganhos expressivos de desempenho são obtidos com o uso de GPUs, as quais são compostas de várias unidades de processamento replicadas. Estas são alimentadas com instruções e dados vindos de memórias existentes na GPU. A replicação das unidades de processamento permite a execução simultânea de vários threads; a organização de memória da GPU fornece uma vazão de instruções e dados adequada, pois é baseada fortemente nos princípios de localidade espacial e temporal de caches (GRAMA et al., 2003); evitando assim concorrer com o barramento e bancos de memória no computador hospedeiro da GPU.

Entretanto, o desenvolvimento de programas concorrentes CUDA envolve a implementação de código fonte para ser executado na CPU e outra parte na GPU. A GPU depende da CPU para fazer a execução de sua parcela de código. Além disso, a arquitetura SIMD e os diversos tipos de memória implicam em diversos desafios na implementação para alcançar um bom desempenho em uma aplicação semanticamente correta (CHENG; GROSSMAN; MCKERCHER, 2014).

Dado esse cenário, desenvolver aplicações com CUDA não é, de fato, uma atividade trivial. Isso ocorre principalmente porque os desenvolvedores estão acostumados a implementar programas sequenciais, onde suas execuções podem ser entendidas por uma pessoa ao observar o fluxo sequencial do código fonte (KIRK; WEN-MEI, 2013). Códigos em CUDA necessitam que o desenvolvedor tenha uma abstração maior e consiga visualizar o que de fato está acontecendo sobre o hardware disponível da GPU.

Tanenbaum e Bos (2014) afirmam que os programadores possuem pouca experiência na implementação de aplicações paralelas.

(5)

Frequentemente profissionais que utilizam esses modelos de programação concorrente não são da área da computação, tendo pouca experiência com o desenvolvimento correto de soluções computacionais.

Isso dificulta o desenvolvimento de aplicações de qualidade que apresentem ganhos de desempenho expressivos e, ao mesmo tempo, tenham poucos defeitos. Ainda segundo os mesmos autores, as aplicações desenvolvidas (concorrentes ou não) apresentam entre dois e dez defeitos não revelados para cada mil linhas de código (TANENBAUM; BOS, 2014).

No caso do modelo de programação CUDA, isso pode acarretar em defeitos de natureza variada. Segundo Cook (2013), alguns defeitos comuns são:

• Acesso inválido de um vetor/matriz: acontece quando não se limita o acesso das threads aos limites das variáveis. Em uma aplicação CUDA, frequentemente o número de threads será diferente do tamanho dos dados, dessa forma não ocorrendo um mapeamento um-para-um entre os mesmos. Esse defeito não é detectado facilmente de maneira estática (antes da execução) e pode ou não acontecer em função do fluxo de dados e da plataforma;

• Qualificador volatile: esse qualificador faz com que leituras e escritas na memória global ou compartilhada sejam feitas diretamente nas mesmas, sem que sejam feitas as devidas verificações e/ou otimizações para as operações utilizando registradores. A falta de uso desses registradores causa a leitura de dados desatualizados, por terem sido alterados por outros threads. Esse defeito é não determinístico e pode resultar em falhas;

• \item Relacionados à sincronia das threads: ocorre quando o resultado depende da ordem de execução das threads e a tal ordem não é garantida por primitivas de sincronização de maneira correta. Esse é um outro defeito não determinístico difícil de ser revelado e reproduzido (SOUZA; VERGILIO; SOUZA, 2007);

• Ainda há os erros que afetam qualquer modelo ou linguagem de programação (sequencial ou concorrente), como utilizar o operador matemático errado, utilizar variáveis incorretas, dentre outros, incluindo os erros comuns a programas concorrentes de outros modelos de programação, como condições de disputa e deadlocks.

(6)

Para melhorar a qualidade do software, há um conjunto de três atividades bem conhecidas que podem ser aplicadas no processo de desenvolvimento do software: Validação, Verificação e Teste (VV\&T) (DELAMARO; MALDONADO; JINO, 2007). A Validação busca garantir que o produto que está sendo construído está de acordo com os requisitos funcionais e não funcionais especificados (BALCI, 1998). A Verificação questiona se o produto está sendo construído corretamente, se o comportamento do mesmo é o esperado, buscando assegurar sua consistência, completitude e corretude. A atividade de Teste tem por objetivo revelar defeitos no programa ou modelo por meio da sua execução; se a aplicação não funcionar corretamente, diz-se que foi revelado um defeito na mesma a (MYERS; SANDLER; BADGETT, 2011).

Neste cenário, o teste de aplicações paralelas de alto desempenho, incluindo aquelas desenvolvidas sob o modelo de programação CUDA, é um desafio (LEI; CARVER, 2006; YANG; POLLOCK, 1997). O teste de tais aplicações paralelas ainda é pouco desenvolvido e utilizado. Há a falta de modelos de teste específicos para extrair informações relevantes para testar tais aplicações, principalmente aquelas relacionadas à comunicação, sincronização e ao não determinismo existentes usualmente em função do comportamento dinâmico das mesmas. Atrelados aos modelos de teste, são necessários novos critérios e ferramentas de teste que deem o suporte adequado à atividade de teste, de maneira a viabilizá-la na prática.

Considerando uma perspectiva histórica, na década de 90 os principais desafios para testar processos concorrentes eram (YANG; POLLOCK, 1997): (i) desenvolver técnicas para a análise estática; (ii) detectar situações indesejadas como: problemas de comunicação, sincronização, de fluxo de dados entre processos e threads e de deadlock; (iii) forçar uma execução determinística (replay de uma execução concorrente) com a mesma entrada de dados; (iv) gerar uma representação do programa concorrente que capture informações requeridas para o teste; (v) investigar se critérios de teste sequenciais são úteis para programas concorrentes; (vi) projetar critérios com foco no fluxo de dados, considerando passagem de mensagens e o uso de variáveis compartilhadas.

O teste de programas concorrentes evoluiu nas últimas duas décadas, porém, diferentes questões ainda estão sem resposta. Uma relação atual (não exaustiva) de possíveis desafios pode ser: (i) desenvolver técnicas de análise estática do código

(7)

fonte combinadas a análise dinâmica, esta oriunda da execução dos processos; (ii) gerar um modelo de teste e critérios de teste que capturem informações de programas concorrentes que interagem concomitantemente por passagem de mensagens e por memória compartilhada; (iii) gerar modelos e critérios de teste ortogonais às linguagens, e modelos de programação em máquinas MIMD/SIMD; (iv) analisar a eficácia de novos critérios em revelar defeitos e estabelecer uma relação de inclusão dos mesmos, com vistas a otimização do teste e redução dos seus custos; (v) desenvolver ferramentas de teste viáveis e ortogonais às linguagens de programação concorrente; (vi) reduzir o custo da atividade de teste em processos concorrentes permitindo a aplicação de modelos e critérios a programas reais; (vii) investigar a aplicação das técnicas funcionais, baseadas em defeitos e estruturais no contexto de programas concorrentes, considerando aspectos complementares, de eficiência e de custo.

Estas questões são constantemente alvo de pesquisas e vêm sendo investigadas no ICMC/USP (Instituto de Ciências Matemáticas e de Computação / Universidade de São Paulo) pelo projeto TestPar - Mecanismos de Apoio ao Teste de Programas Paralelos (HAUSEN et al., 2007; SARMANHO et al., 2008; SOUZA et al., 2005; SOUZA et al., 2008; VERGILIO; SOUZA; SOUZA, 2005; SOUZA; SOUZA; ZALUSKA, 2012). De ordem prática, conforme as pesquisas desenvolvidas no projeto TestPar avançam, novas perguntas surgem e aumentam a lista de desafios acima. O projeto TestPar investiga o teste de programas concorrentes de maneira abrangente, considerando modelos, critérios e ferramentas de teste estrutural no contexto de aplicações baseadas nos paradigmas de memória compartilhada e passagem de mensagem para máquinas MIMD.

Os modelos representam informações sobre fluxos de controle e de dados de processos concorrentes. Os critérios fornecem uma medida de cobertura para avaliar o progresso do teste e assim guiar a geração de novos casos de teste. A ferramenta ValiPar implementa os conceitos investigados e instanciados nos modelos e critérios de teste (SOUZA et al., 2008). Há versões desta ferramenta instanciadas para programas concorrentes com passagem de mensagem e memória compartilhada. São representantes do primeiro grupo as ferramentas ValiPVM, ValiMPI e ValiBPEL (SOUZA et al., 2008; HAUSEN et al., 2007; ENDO et al., 2008). No segundo grupo está a ValiPThread (SARMANHO et al., 2008) que testa programas implementados em C/Pthread. A ValiPar para Java unifica os modelos de

(8)

passagem de mensagem e memória compartilhada (SOUZA et al., 2013; PRADO et al., 2015).

Apesar da evolução que o projeto TestPar já trouxe para a área de teste de programas concorrentes, um dos desafios ainda a serem descobertos é como aplicar o teste estrutural de programas concorrentes no contexto da programação CUDA, de modo a revelar defeitos difíceis de serem revelados. Este desafio é o foco principal deste projeto de pesquisa.

Os modelos de teste já desenvolvidos não se aplicam a CUDA devido ao modelo de programação ser diferente, pois a arquitetura a que ele se aplica a arquitetura utilizada é distinta do foco dos modelos existentes, onde eles se aplicam para arquiteturas MIMD, enquanto CUDA foi desenvolvido para arquitetura similar a SIMD.

O desenvolvimento deste projeto requer superar alguns desafios, não triviais, ainda sem resposta. Alguns desses desafios são: (i) geração e interação implícitas de

threads (O modelo de programação CUDA encapsula os detalhes de

implementação para a geração de threads e a sincronização/comunicação destas e isso é um fator complicador para o teste estrutural. Não se sabe ainda como coletar tais informações que estão implícitas em tais comandos CUDA); (ii) quais

informações coletar do código fonte; (iii) há a falta de uma taxonomia de defeitos para programas CUDA; (iv) quais critérios de teste seriam eficazes para revelar defeitos em programas CUDA; (v) como deve ser construída uma ferramenta de teste para automatizar tal atividade; (vi) quais dos conhecimentos

já desenvolvidos em teste de software poderiam ser reutilizados para viabilizar o teste em programas concorrentes em CUDA, reduzindo assim os custos para o seu desenvolvimento.

3.3.2. Justificativa

A principal justificativa para o desenvolvimento deste projeto é que os programas concorrentes em CUDA desenvolvidos até o momento não têm o devido suporte de uma atividade de teste, capaz de determinar se tais programas CUDA foram testados adequadamente. Essa situação pode ser verificada pela natureza e pouca quantidade de trabalhos relacionados encontrados na literatura sobre o teste de programas concorrentes com CUDA. Tal situação implica em códigos falhos, com

(9)

defeitos não revelados, fato que compromete significativamente a qualidade das soluções paralelas propostas com CUDA.

De fato, não se conhece, até o momento, como devem ser construídos modelos, critérios e ferramentas de teste para dar suporte apropriado para tal atividade. Além disso, não se tem conhecimento como tais recursos de teste devem ser aplicados para revelar defeitos não conhecidos e, assim, minimizar o custo do desenvolvimento e da manutenção de programas concorrentes em CUDA.

Dessa forma, o desenvolvimento deste projeto de pesquisa justifica-se por buscar importantes respostas, ainda em aberto, sobre o teste de HPC com CUDA.

A principal delas é entender como quantificar e qualificar se um programa CUDA foi bem testado. Os aspectos quantitativos vêm da cobertura de novos critérios de teste e os aspectos qualitativos vêm da capacidade desses critérios em revelar defeitos esperados e difíceis de serem revelados sem o suporte de uma atividade de teste criteriosa.

Como heurística, assume-se que se os critérios foram comprovadamente capazes de revelar defeitos e foram satisfeitos, então o programa em teste foi bem testado e, potencialmente, tem uma melhor qualidade.

3.3.3. Fundamentação teórica

Este capítulo apresenta os conceitos básicos necessários para a execução deste trabalho. Desta forma são abordados aspectos arquiteturais, um modelo de programação, técnicas e critérios de teste elencados na literatura.

3.3.3.1. Conceitos de GPU

GPU é uma evolução de um controlador VGA (Video Graphic Array). O objetivo do controlador VGA era apresentar informações gráficas no monitor. Com o tempo, ele passou a incorporar novas funcionalidades, como funções voltadas para o processamento de gráficos tridimensionais (PATTERSON; HENNESSY, 2013). Com a incorporação cada vez maior de novas funções de processamento à VGA, ela passou a se tornar de fato um processador (PATTERSON; HENNESSY, 2013). Devido a essas mudanças, em 1999 a Nvidia criou o nome GPU (NVIDIA,2009). Ao ser criado, a GPU teve por finalidade básica o processamento massivo de gráficos tridimensionais. Ele passou a ter métodos para transformação, luz, motores

(10)

de renderização, dentre outras funções, tendo capacidade na época de processar 10 milhões de polígonos por segundo (NVIDIA, 1999). O interesse em utilizar a GPU para processamento de propósitos gerais apareceu devido ao desempenho que ele passou a apresentar (COOK, 2013), principalmente para o processamento de ponto flutuante (PATTERSON; HENNESSY, 2013). A Figura 1 apresenta o crescimento de desempenho da GPU para ponto flutuante simples e precisão dupla. Na figura não é possível observar diferenças palpáveis entre os dois no ano de 2002. No entanto a partir de 2004 a GPU passou a oferecer um maior desempenho em relação a CPU, neste mesmo ano foi apresentado o BrookGPU (BUCK et al., 2004), a primeira linguagem voltada para a implementação de aplicações de propósitos gerais para GPU. Em 2006 a GPU apresentou um desempenho, nesse cenário, várias vezes maior que a CPU. A partir daí, a cada geração houve um grande aumento de desempenho.

Figura 1 – Operações de ponto flutuante por segundo para CPU e GPU.

(11)

Inicialmente para programar programas não gráficos era necessário utilizar HLSL (High Level Shading), por meio de API (Application Programming Interface) gráfica, como DirectX e OpenGL (Open Graphics Library) (NVIDIA, 2009). Esse modelo de programação que usa APIs gráficas para computação de propósito geral foi denominado GPGPU (General Purpose computation on Graphics Processing Unit) \cite{patterson:2013:computer}.

Com o tempo, a GPU se tornou cada vez mais programável, conforme unidades de processamento foram substituindo funções de lógica dedicada, enquanto manteve suas capacidades de processamento 3D (PATTERSON; HENNESSY, 2013). Ele passou a permitir cálculos mais precisos, adicionando capacidades para processamento de ponto flutuante com precisão simples e dupla.

Em 2006 a NVIDIA apresentou um novo modelo arquitetural e um novo modelo de programação chamado CUDA (NVIDIA, 2009). Esse modelo de programação abandonou a necessidade de utilizar APIs gráficas para o desenvolvimento de aplicações de propósito geral e passou a permitir a utilização de linguagens de programação conhecidas, como C e Fortran, combinado com extensões CUDA, para implementar aplicações voltadas para GPU. Essa mudança de paradigma na

implementação abandonou o termo GPGPU e foi chamada de “Computação GPU”

(GPU Computing) (PATTERSON; HENNESSY, 2013).

O modelo arquitetural CUDA pode ser entendido por meio da Taxonomia de Flynn (1972), que classifica as arquiteturas de processamento paralelo da seguinte forma:

• SISD (Single Instruction; Single Data) - Um único processador executa um único fluxo de instruções que opera em dados armazenados em uma única memória a (STALLINGS, 2012). A Figura 2 (a) apresenta um exemplo desse tipo de sistema.

• SIMD (Single Instruction; Multiple Data) - Uma única unidade de controle define uma única instrução para ser executada por diversas unidades de processamento. Cada unidade de processamento está associada a um dado de memória diferente, dessa forma uma mesma instrução é executada em dados diferentes no mesmo instante de tempo (STALLINGS, 2012). Na Figura 2 (b) é demonstrado a organização desse sistema.

• MISD (Multiple Instruction; Single Data) - Várias unidades de processamento com unidades de controle dedicadas operam em um mesmo fluxo de dados executando instruções diferentes (PATTERSON; HENNESSY, 2013).

(12)

• MIMD (Multiple Instruction; Multiple Data) - Várias unidades de processamento executam instruções distintas em dados diferentes (STALLINGS, 2012). Processadores x86_64 se encontram nesta categoria. A Figura 2 mostra duas organizações desse sistema, uma com memória compartilhada (c) e outra com memória distribuída (d). O primeiro pode ser observado em computadores domésticos e o segundo em clusters.

A GPU baseia-se no modelo SIMD, enquanto as atuais CPUs utilizam o modelo MIMD. Esses modelos possuem o funcionamento e finalidade diferentes. Com isso, o núcleo da GPU tem foco em tarefas com alta paralelização de dados, possuindo lógica de controle simples e focando na vazão de dados. A CPU possui uma lógica de controle complexa, sendo dessa forma mais focado em programas sequenciais. Ela oferece bom desempenho em programas que apresentam muitas mudanças no fluxo de controle, causadas por estruturas de repetição e decisão, enquanto a GPU sofre um forte impacto no desempenho nesses casos (CHENG; GROSSMAN; MCKERCHER, 2014). AA Figura 3 ilustra as diferenças de foco nos núcleos de ambas arquiteturas, onde a GPU dedica grande espaço para processamento de dados e pouco para controle e cache; a CPU, por sua vez, dedica mais espaço para controle e cache.

Figura 2 – Organização de sistemas paralelos.

(13)

Figura 3 – Diferença arquitetural.

Fonte: Cheng, Grossman e McKercher (2014).

A arquitetura CUDA se baseia no modelo SIMD, mas também possui similaridades com o modelo MIMD. A NVIDIA chamou esse tipo de arquitetura de SIMT (Single Instruction; Multiple Threads) (CHENG; GROSSMAN; MCKERCHER, 2014). Esse modelo arquitetural permitir gerenciar, escalonar e executar grupos de threads chamados de warp. Por definição, um warp pode ter até 32 threads.

A arquitetura da GPU NVIDIA sofreu diversas alterações desde a divulgação do modelo CUDA em 2006. A arquitetura que será utilizada no desenvolvimento deste trabalho é a Kepler, devido a GPU que será utilizada possuir essa arquitetura. Por isso ela será utilizada para ilustrar o funcionamento da arquitetura.

A arquitetura Kepler possui diversos Stream Processors (SP), chamados de CUDA Core. Cada CUDA Core possui uma unidade de processamento de inteiros e uma unidade de processamento de ponto flutuante simples.

Os CUDA Cores são agrupados em Stream Multiprocessors (SP). Na arquitetura Kepler são chamados de SMX, onde há dezenas de CUDA cores que permite o processamento de dados de precisão simples. Para o processamento de ponto flutuante de precisão dupla há as unidades DP (Double Precision Unit). LD/ST (Load/Store) são usadas para armazenar e buscar dados na memória. Ainda há as unidades Special Function Unit (SFU), voltada para o cálculo de funções especiais, como seno e cosseno (CHENG; GROSSMAN; MCKERCHER, 2014). Essas quatro unidades definem o conjunto de instruções que podem ser executadas. O SMX ainda possui cache dedicado aos CUDA Cores e registradores exclusivos para cada CUDA Core. Há cache de instrução. O Warp Scheduler escalona os warps aos núcleos para serem executados. Por fim há o dispatch que envia as instruções as unidades de processamento. Nessa arquitetura em específico há dois dispatch por

(14)

warp scheduler, permitindo que duas instruções diferentes sejam processadas por warp, no entanto nas primeiras arquiteturas havia a proporção de um para um, permitindo somente uma instrução por warp.

Uma GPU Kepler pode possuir mais de um SMX. Em sua versão completa há 15 unidades SMX (NVIDIA Corporation, 2012) com 192 CUDA cores cada. Ainda há um cache L2 compartilhado entre os SMX, um escalonador de trabalho para enviar a carga para cada unidade SMX chamado GigaThread Engine e o controlador de memória.

Para utilizar a GPU da NVIDIA para computação de propósito geral há o modelo de programação CUDA, que implementa diretivas que permitem o desenvolvimento de aplicações de forma similar à modelos de programação para CPU.

3.3.3.2. Modelo de programação CUDA

O CUDA é um modelo de programação concorrente heterogêneo, pois no desenvolvimento de uma aplicação CUDA é necessário implementar código para ser executado na CPU e outra parcela para a GPU. Isso é devido à dependência que a GPU possui, onde ela depende da CPU para enviar os comandos sobre o que deve ser executado, assim como transferir dados da memória principal para a memória da GPU. Por isso a GPU é um coprocessador.

Dessa forma, no desenvolvimento o código fonte é dividido em duas partes, a parcela executada na CPU, também chamada de host, e a parcela executada na GPU, chamada de device (FARBER, 2012). As parcelas de código executadas no device são denominadas kernel. No desenvolvimento, o kernel pode ser escrito como um código sequencial, no entanto cada thread irá executar uma cópia desse kernel, com o conjunto de threads sendo executados concorrentemente na GPU. O código do host pode operar de forma independente do código da GPU (CHENG; GROSSMAN; MCKERCHER, 2014). Quando um kernel é executado, o controle retorna imediatamente ao host, liberando a CPU para executar atividades complementares durante a execução do kernel no device. Em geral, o modelo de programação CUDA é assíncrono, com grande parte de suas funções sendo executadas de forma assíncrona, para que possa haver uma sobreposição de computação entre CPU e GPU, a menos que a função diga o contrário. Com isso, um programa típico CUDA costuma ter uma parte serial ou paralela do host e outra parte paralela para ser executada na GPU (KIRK; WEN-MEI, 2013). A Figura 4

(15)

ilustra o processo de execução típico de uma aplicação CUDA, que costuma seguir o seguinte padrão:

• Copia dados da memória da CPU para a memória da GPU;

• Invoca a execução de um kernel na GPU para operar nos dados transferidos; • Copia os dados da memória da GPU de volta para a memória da CPU.

Figura 4 – Fluxo de execução típico de uma aplicação CUDA.

Fonte: Cheng, Grossman e McKercher (2014).

Para o desenvolvimento de uma aplicação, o modelo CUDA fornece um meio de organização de threads e um meio de acesso à memória, ambos por uma estrutura hierárquica (WILT, 2013). Na hierarquia de threads há como menor unidade a thread que são agrupados em warps, sendo a menor unidade de escalonamento deste modelo de programação. Os warps são agrupados em blocks (blocos), com uma limitação de 1024 threads por bloco. Por fim estes são agrupados em um grid (grade). De ordem prática, esses aspectos são definidos no host durante a chamada de kernel, que iniciará a execução do kernel na GPU (SANDERS; KANDROT, 2011). A chamada do kernel é feita por meio da sintaxe kernel<<<gridSize, blockSize>>>, sendo blockSize o número de threads que cada block contém e gridSize o número de blocks que cada grid possui. A chamada do kernel então cria um grid, contendo vários blocks. O block é dividido por warps de até 32 threads pelo escalonador do SM. Essa hierarquia é apresentada na Figura 5. Os parâmetros gridSize e blockSize podem ser representados em uma, duas ou três dimensões, possuindo identificadores para localizar a thread no espaço uni, bi, ou tridimensional do bloco, assim como no espaço do grid, utilizando os parâmetros threadIdx (threadIdx.x,

(16)

respectivamente. Esses parâmetros definem o número de threads totais que serão executadas. Uma GPU atual permite que milhares de threads sejam executadas concorrentemente.

A hierarquia de memória, apresentada na Figura 5 está intimamente ligada à hierarquia das threads. Nessa hierarquia há a memória local do thread, onde cada thread tem acesso exclusivo a esse conteúdo que pode estar armazenado nos registradores ou na memória principal da GPU. Cada block possui uma memória compartilhada, que pode ser utilizada para a comunicação entre os threads do block. A GPU possui memória global, em que todos os threads e blocks do grid possuem acesso e pode ser usada para a comunicação entre threads de blocks diferentes ao custo de alta latência nessa comunicação em comparação com a comunicação dentro de um block usando memória compartilhada a (CHENG; GROSSMAN; MCKERCHER, 2014).

Figura 5 – Hierarquia de thread e memória do modelo de programação CUDA.

Fonte: NVIDIA (2015).

(17)

Usando esse modelo de programação, o Código-fonte 1 demonstra um possível defeito em aplicações que utilizam esse paradigma. Ele apresenta uma função em linguagem CUDA C, com qualificador “__global__” que o define como kernel, dessa forma sendo executado na GPU. O objetivo deste kernel é deslocar os elementos de um vetor em uma posição para a esquerda. Para melhorar o desempenho dessa atividade é utilizado memória compartilhada por meio do qualificador de variável “__shared__”. Cada thread irá copiar um elemento do vetor referente à posição indicada pelo identificador do thread e escrevê-lo na posição anterior. No entanto essa atividade está sendo realizada no mesmo vetor, dessa forma, para que este programa apresente a semântica esperada é necessário que todos os threads leiam o valor do vetor e então escrevam na posição anterior. Tendo um comportamento não-determinístico, não há garantias que a execução ocorrerá nessa ordem. Em diversas replicações de execução da aplicação ela pode apresentar o comportamento esperado, no entanto em certas replicações apresentar erro. Para garantir a semântica na execução, e dessa forma corrigir o erro, é necessário utilizar uma barreira de sincronização entre a atividade de leitura dos valores pelas threads e o armazenamento na posição anterior.

(18)

O Código-fonte 2 apresenta um outro tipo de defeito em aplicação CUDA. Neste exemplo milhares de threads irão somar um valor a uma posição de um vetor. O vetor possui dez posições, como não há garantias quanto a ordem de execução e momento de interrupção das threads, determinadas threads podem somar valores ao vetor sem considerar a somatória feita por outros threads devido à falta de garantia de atomicidade do processo de leitura, soma e escrita do valor do vetor. Neste caso em específico, detectar a presença de um feito é relativamente fácil, pois a quantidade de threads trabalhando em cada posição do vetor é grande, por consequência a chance de ocorrer uma falha também é, no entanto, em outras situações em que o número de threads por posição é menor, e respeitando o

(19)

agrupamento de threads em warps, pode-se acarretar em situações difíceis de serem detectadas devido ao não-determinismo da execução.

Apesar das facilidades trazidas com o modelo de programação CUDA para o desenvolvimento de programas concorrentes, o mapeamento dos dados e tarefas pelas threads não é um processo simples, dependendo do problema a ser resolvido, o que pode acarretar em falhas difíceis de serem reveladas e/ou depuradas. Durante esse mapeamento o desenvolvedor ainda precisa verificar outras questões de implementação, que podem resultar em deterioração de desempenho ou erros durante a execução, como realizar acesso coalescente à memória principal, diminuir conflitos às memórias compartilhadas, mitigar divergências de threads que podem

(20)

ocorrer devido à arquitetura SIMT da GPU. Por fim, é necessário que o desenvolvedor considere a utilização de barreiras de sincronização para manter a semântica da aplicação, assim como tornar certas operações atômicas.

3.3.3.3. Teste de Software

O objetivo do teste de software} é garantir a consistência do sistema, averiguando se ele se comporta como esperado e especificado ou se não realiza ações inesperadas (MYERS;SANDLER; BADGETT, 2011). Para isso ele busca detectar a presença de defeitos. Caso defeitos sejam detectados, as informações adquiridas durante a execução do teste que revelou a presença do defeito podem ajudar na atividade de depuração para localizar e corrigir. Nesse contexto, alguns vocábulos que popularmente são usados ambiguamente, têm seu significado definido. A norma IEEE (1990) definiu alguns termos que serão utilizados neste trabalho, sendo eles o “engano”, “defeito”, “erro” e “falha”. Seus significados são:

• Engano (mistake) - Uma ação humana que produziu um resultado incorreto. • Defeito (fault) - um passo, processo ou definição de dados incorreto, por

exemplo, uma instrução incorreta em um programa.

• Erro (error) - A diferença entre um valor ou condição computado, observado ou medido e o valor ou condição teoricamente correta ou especificada.

• Falha (failure) - Um resultado incorreto. Por exemplo, um resultado calculado de 12, quando o resultado correto é 10.

A Figura 6 demonstra a relação dos quatro termos, onde o desenvolvedor comete um engano. Este engano introduz um defeito no sistema. O defeito pode produzir um erro, que se propaga na saída do sistema na forma de falha. Apesar da

padronização, é comum utilizar “erro” se referenciando a “defeito”, “erro” ou “falha”

(21)

Figura 6 – Relação de engano, defeito, erro e falha.

Assim como o processo de desenvolvimento de software, o teste é dividido em algumas fases. Myers, Sandler e Badgett (2011) destacam várias fases do processo de teste de software, mas para a detecção de defeitos há quatro fases. Três fases executadas durante o processo de desenvolvimento do software e uma fase que ocorre após a entrega, durante o período de manutenção. As quatro fases são:

• Teste de unidade - também chamado de teste de métodos (MYERS; SANDLER; BADGETT, 2011). Nesta fase, são testadas isoladamente as unidades do sistema, como funções, procedimentos e métodos. Em programação orientada a objetos, as unidades podem ser as classes. Essa etapa busca identificar erros relacionados aos requisitos dos métodos, utilizando dados de teste para averiguar se o comportamento observado é igual ao especificado (IEEE, 1986).

• Teste de integração - Tem a finalidade de procurar por defeitos na interação das unidades do sistema, quando duas ou mais unidades são combinadas. • Teste de sistema - A terceira fase busca assegurar que o sistema como um

todo, quando todas as unidades do sistema foram integradas, está funcionando dentro dos requisitos especificados. Além da detecção de defeitos, nesta fase ainda é explorado aspectos de segurança, desempenho e robustez (DELAMARO; MALDONADO; JINO, 2007).

• Teste de regressão - Está fase não está inclusa no processo de desenvolvimento do sistema. Ele ocorre após a entrega do sistema, durante o período de manutenção do software. Ao ocorrer modificações no sistema ou inclusão de novas funcionalidades, o teste de regressão busca garantir que foram corretamente implementadas e que as funcionalidades anteriormente

(22)

testadas continuam funcionando corretamente (DELAMARO; MALDONADO; JINO, 2007).

Para cada fase realizam-se quatro etapas da atividade de teste: planejamento; projetos de casos de teste; execução; análise. A Figura 7 ilustra um cenário de uma atividade de teste (DELAMARO; MALDONADO; JINO, 2007). P é o programa em teste. D o domínio de entrada do programa, ou seja, o conjunto com todos os valores de entrada possíveis (válidos e não válidos). T é o conjunto de casos de teste, onde um caso de teste é constituído por um dado de teste (um elemento do domínio) e a saída esperada para esse dado de teste. S é a especificação do programa. Nesse cenário são escolhidos alguns casos de teste do domínio D(P). Esse conjunto de casos de teste é aplicado no programa e o testador, baseado na especificação, define se obteve sucesso ou falha.

Figura 7 – Cenário típico da atividade de teste.

Fonte: Adaptado de Delamaro, Maldonado e Jino (2007)

Para garantir que um programa está livre de defeitos é necessário fazer um teste exaustivo, no entanto, isso é impraticável (MYERS; SANDLER; BADGETT, 2011).. Imagine um programa onde há uma única variável de entrada, um número inteiro de

4 bytes. Em um teste exaustivo é necessário 216 casos de teste. Em um programa

real, onde há diversas variáveis e é necessário testar todas as suas combinações, o teste exaustivo se torna impossível, pois é necessário considerar questões econômicas e de tempo na sua aplicação (MYERS; SANDLER; BADGETT, 2011). Buscando diminuir o número de casos de teste necessários e mantendo uma alta probabilidade de revelar defeitos, técnicas e critérios de teste foram criados. A técnica de teste define a origem da informação utilizada, enquanto os critérios de teste definem as regras de seleção ou cobertura de casos de teste baseado na origem da informação. Dessa forma, cada técnica pode possuir diversos critérios de

(23)

teste. Há diversas técnicas de teste de software}, algumas das principais são teste funcional, teste baseado em defeitos e teste estrutural.

A. Teste funcional

Esta técnica considera o sistema como uma caixa preta, dessa forma não se tem acesso ao código fonte do programa. Para definir os casos de teste é utilizado a especificação do sistema (MYERS; SANDLER; BADGETT, 2011). Isso exige que a especificação esteja atualizada e exprima corretamente o sistema. Nessa técnica as entradas são fornecidas baseadas em algum critério e as saídas do sistema são aferidas para verificar se o comportamento é igual ao esperado. Como não é viável testar todo o espectro dos domínios de entrada de uma aplicação, há alguns critérios para a seleção dos dados de teste. Os critérios mais conhecidos são: particionamento em classes de equivalência; análise do valor limite; grafo causa-efeito e error-guessing} (FABBRI;VINCENZI; MALDONADO, 2007).

Particionamento em classes de equivalência - Este critério busca particionar o

domínio de entrada do sistema em classes de equivalência. Cada classe representa um conjunto de estados válidos ou inválidos e pressupõe-se que qualquer elemento dessa classe representa muito bem todos os elementos dela. Dessa forma é esperado que se um elemento dessa classe consegue revelar um defeito, todas as outras também conseguem. Os casos de teste são criados com base nas classes de equivalência, escolhendo aleatoriamente o dado de teste de cada classe.

Análise do valor limite - Estende o particionamento em classes de equivalência. Ao

invés de escolher os elementos aleatoriamente para os casos de teste, são selecionadas as fronteiras das classes de equivalência. Com isso são testados os valores mínimo e máximo de uma entrada, bem como o valor subsequente que esteja fora desse limite.

Grafo causa-efeito - Os critérios anteriores não exploram o efeito da combinação

das entradas. O critério Grafo Causa-Efeito busca explorar isso. A “causa” é a

entrada do sistema, enquanto o “efeito” é a sua saída. Essa relação é representada em um grafo que é convertido em uma tabela de decisão. Os casos de teste são derivados da tabela.

Error-guessing - Baseado na experiência do testador. Os casos de teste são

definidos com base em possíveis erros que o sistema possa apresentar com base em experiências prévias.

(24)

B. Teste baseado em defeitos

Esta técnica utiliza a implementação do sistema e se baseia nos tipos mais comuns de defeitos encontrados durante o processo de desenvolvimento de software. Um critério conhecido desta técnica é o Teste de Mutação, também chamado de Análise de Mutantes. Como um bom caso de teste tem uma alta probabilidade de revelar defeitos (DELAMARO et al., 2007), um objetivo dessa técnica é avaliar a qualidade do conjunto de casos de teste. Neste critério, modificações sintáticas são inseridas no código original, gerando versões chamadas mutantes. Os casos de teste são então usados na execução do código original e dos mutantes. Na circunstância da saída do código original e dos mutantes serem iguais, os códigos podem ainda conter erros e neste caso o dado de teste usado foi pouco sensitivo para revelar o defeito, ou os mutantes são equivalentes (DEMILLO; LIPTON; SAYWARD, 1978). Neste último caso as modificações sintáticas não produziram semânticas diferentes em relação ao programa original. No final do processo terá um conjunto de casos de teste adequado a aplicação, que permite revelar defeitos que o programa original não possui.

C. Teste estrutural

A técnica de teste estrutural utiliza a implementação do programa. Os requisitos de teste são derivados das estruturas internas do programa, por isso esta técnica também é conhecida como teste de caixa branca a (MYERS; SANDLER; BADGETT, 2011; BARBOSA et al., 2007), onde se tem conhecimento do código fonte.

Os critérios de teste estrutural utilizam um grafo direcionado para representação do programa, denominado GFC (Grafo de Fluxo de Controle) ou Grafo de Programa (BARBOSA et al., 2007). Neste grafo o programa é representado por meio de nós e arestas. Os nós são blocos de instruções não divisíveis do programa, ou seja, ao executar a primeira instrução do bloco todos os outros serão obrigatoriamente executados. As arestas são os desvios de fluxo de controle causados por laços de repetição e estruturas de decisão. A Figura 8 apresenta um grafo de exemplo que possui estruturas de decisão, como um if nos Nós 1, 2 e 3, e laços de repetição envolvendo os Nós 4, 5, 6 e 7.

(25)

Fonte: Adaptado de Barbosa et al. (2007)

No teste estrutural busca-se executar os caminhos do grafo. Um caminho é uma

sequência de nós do grafo. Este caminho pode ser um “caminho simples”, onde

todos os nós são distintos, exceto possivelmente o primeiro e último (BARBOSA et

al., 2007). Pode ser um “caminho livre de laço”, em que todos os nós são distintos.

Um “caminho completo”, quando o primeiro nó é a entrada e o último nó é o nó de saída do grafo (BARBOSA et al., 2007).

Os critérios de teste estrutural são definidos com base nos elementos do grafo, como os nós e desvios condicionais. A finalidade é garantir a cobertura desses elementos pelo conjunto de casos de teste, sendo que a cobertura é uma métrica para avaliar a qualidade do conjunto com base no número de elementos executados. No teste estrutural os critérios são divididos em três categorias: critérios baseados na complexidade, critérios baseados no fluxo de controle e critérios baseados no fluxo de dados.

Critérios baseados na complexidade

Utilizam informações sobre a complexidade do programa para derivar os requisitos de teste. Como exemplo de critério baseado na complexidade, o critério de McCabe

(MCCABE,1976), também conhecido como “teste do caminho básico”, utiliza a

complexidade ciclomática do GFC para obter os requisitos de teste. A complexidade ciclomática define o número de caminhos linearmente independentes do GFC, dessa forma oferecendo um limite máximo para o número de casos de teste para garantir

(26)

ao menos uma execução de cada instrução do programa (BARBOSA et al., 2007). Caminhos linearmente independentes são caminhos que introduzem uma nova aresta que não foi coberta por um dos caminhos anteriormente definidos. O conjunto de caminhos independentes derivados do GFC não é único, podendo haver diferentes conjuntos para o mesmo programa. De acordo com Pressman (2010), a complexidade ciclomática V(G) pode ser calculada de três formas:

1. Número de regiões do GFC. Regiões são as áreas do grafo delimitadas pelas arestas e nós. Na Figura 8 há 5 regiões.

2. V(G) = E - N + 2, onde E é o número de arestas e N o número de nós.

3. V(G) = P + 1, onde P é o número de nós predicados do GFC. Nó predicado é o nó que possui mais de uma aresta de saída. No grafo da Figura 8 os Nós 1, 4, 5 e 8 são predicados.

Critérios baseados no fluxo de controle

Utiliza o GFC para derivar características de fluxo de controle da execução do programa, como comandos e desvios, para definir os elementos requeridos no processo de teste (BARBOSA et al., 2007). Como critérios conhecidos, ele possui:

• Todos-nós - Cada nó do GFC precisa ser executado ao menos uma vez pelo conjunto de casos de teste, fazendo com que todos os comandos do programa sejam executados.

• Todas-arestas - Todas as arestas do GFC são cobertas pelos casos de teste, ou seja, todos os desvios de fluxo de controle são exercitados.

• Todos-caminhos - Todos os caminhos possíveis do GFC são executados. No entanto, dependendo do programa este critério pode ser impraticável, pois na presença de laços o número de caminhos pode tender ao infinito (BARBOSA et al., 2007).

Critérios baseados no fluxo de dados

Este critério busca testar o uso das variáveis do programa, por meio da análise do fluxo de dados para derivar os requisitos de teste. Este critério utiliza uma extensão do GFC, denominado Grafo Def-Uso (Definição-Uso), proposto por Rapps e Weyuker (1982). Neste grafo são adicionadas informações relacionadas ao fluxo de dados do programa, como a definição e utilização de uma variável. A definição de uma variável ocorre quando um valor é atribuído a ela. Um uso ocorre quando a

(27)

variável é utilizada, que pode acontecer de duas formas denominadas “uso predicativo” (p-uso) e “uso computacional” (c-uso). P-uso é caracterizado pela utilização da variável em estruturas de repetição e de decisão, como while e if. C-uso são as utilizações computacionais da variável, quando não há modificação do seu valor, pois ele é usado do lado direito de uma atribuição. Em um grafo, o p-uso ocorre nas arestas e o c-uso ocorre nos vértices. Com base nisso, Rapps e Weyuker (1982) definiram alguns critérios, sendo os principais (BARBOSA et al., 2007):

• Todas-definições - Cada definição de variável precisa ser exercitada ao menos uma vez, seja por um c-uso ou p-uso.

• Todos-usos - Todas as associações entre a definição da variável e os subsequentes c-usos e p-usos por pelo menos um caminho livre de definição. Caminhos são livres de definição quando a variável não é redefinida entre a definição e o seu uso. Algumas variações deste critério são: Todos-p-usos, Todos-p-Usos/Alguns-c-Usos e Todos-c-usos/Alguns-p-usos.

• todos-du-caminhos - Todas as associações de definição de uma variável e seus usos precisam ser exercitadas por todos os caminhos livres de definição e livres de laço.

3.3.3.4. Teste de programas concorrentes

O teste de programas concorrentes é mais complexo do que o teste de programas sequenciais devido ao comportamento não-determinístico \cite{souza:2007:book}. Programas sequenciais possuem comportamento determinístico, por isso ao executar um programa sequencial diversas vezes usando como entrada o mesmo dado de teste, a saída resultante será igual. No caso de um programa concorrente, sendo exercitado em um mesmo cenário, onde ele é executado repetidas vezes usando o mesmo dado de entrada, devido ao não-determinismo a saída resultante de cada replicação pode ser diferente o (SOUZA; VERGILIO; SOUZA, 2007). A

Figura 9 apresenta um exemplo de não-determinismo, onde os threads T0 e T2

(28)

Figura 9 – Exemplo de não-determinismo.

Fonte: Sarmanho et al. (2008).

Isso ocorre devido à comunicação e sincronismo inerentes de programas concorrentes. Programas concorrentes são compostos de processos e/ou threads que executam concorrentemente buscando resolver juntos um determinado problema (SARMANHO et al., 2008). Para que trabalhem juntos é necessário fazer a comunicação entre processos/threads. No entanto, não há garantias sobre a ordem que os processos/threads serão executados, sendo necessário fazer a sincronização deles. A sincronização possui dois objetivos: o primeiro é garantir a ordem de execução dos processos/threads; o segundo objetivo é a exclusão mútua, controlando o acesso a regiões críticas, para garantir a consistência do valor de uma variável, por exemplo.

A comunicação e sincronização podem ocorrer por passagem de mensagens e por memória compartilhada. Devido ao modelo de programação CUDA utilizar memória compartilhada, está seção focará no teste de programas concorrentes com memória compartilhada, não abordando as técnicas e critérios voltados para o teste de programas que utilizem passagem de mensagem.

Esta seção aborda o teste de programas concorrentes com memória compartilhada, destacando a abordagem de teste estrutural proposta por Sarmanho et al. (2008). Sarmanho et al. (2008) propõem critérios de teste estrutural para programas concorrentes com memória compartilhada implementadas com PThreads. Esses critérios consideram a comunicação entre processos, sincronização por meio de semáforos e o não-determinismo intrínseco de programas concorrentes. Semáforo é um mecanismo para coordenar a execução das threads no paradigma de memória compartilhada.

(29)

No teste estrutural os critérios utilizam uma representação em grafo do programa. Para programas concorrentes, há o GFCP (Grafo de Fluxo de Controle Paralelo) (VERGILIO; SOUZA; SOUZA, 2005) voltado para programas concorrentes com passagem de mensagem. Sarmanho et al. (2008) adaptam o GFCP para o contexto

de memória compartilhada, usando o GFCPMC (Grafo de Fluxo de Controle Paralelo

para Memória Compartilhada).

O GFCPMC é composto por diversos GFC (um para cada thread do programa), e por

uma representação da sincronização entre threads. O grafo considera as sincronizações explícitas do semáforo, que usam as primitivas atômicas post e wait; e as sincronizações causadas pela inicialização e finalização das threads. A construção do grafo é feita por meio de análise estática do código fonte, onde são extraídas essas informações. A Figura 10 mostra a representação de um algoritmo

de produtor-consumidor com três threads em um GFCPMC, que contém inclusive

informações relacionadas ao fluxo de dados. Na Figura podemos observar características diferentes do grafo para programas sequências, devido a presença de sincronização, como as sincronizações relacionadas aos semáforos, representadas pelas primitivas post e wait.

(30)

Figura 10 – GFCPMC de um algoritmo produtor-consumidor.

Fonte: Sarmanho (2009).

O grafo contém informações relacionadas à definição e uso das variáveis locais e compartilhadas. Podem ocorrer cinco tipos de uso de variáveis em programas concorrentes com memória compartilhada (SARMANHO et al., 2008) sendo eles:

• uso computacional (c-uso) - Uso de variável local em uma computação. Também utilizado no teste sequencial.

• uso predicativo (p-uso) - Uso de variável em estruturas de repetição e decisão que mudam o fluxo de controle. Também utilizado no teste sequencial.

• uso de sincronização (sinc-uso) - Uso de variável compartilhada em funções de sincronização, como semáforo.

(31)

• c-uso comunicacional (com-c-uso) - Uso de variável compartilhada em uma computação.

• p-uso comunicacional (com-p-uso) - Uso de variável compartilhada em estruturas de repetição e decisão que mudam o fluxo de controle.

Com base nos tipos de utilização de uma variável, pode-se caracterizar as associações entre a definição e uso da variável, que são utilizados em critérios de fluxo de dados, considerando associações em um mesmo thread e entre threads diferentes. Sarmanho et al. (2008) definiram cinco tipos de associações, sendo elas:

• associação c-uso - Há uma definição e um c-uso de uma variável local no thread e existe pelo menos um caminho livre de definição que relaciona a definição e uso. Utilizado no critério Todos-usos do teste estrutural sequencial.

• associação p-uso - Há uma definição e um p-uso de uma variável local no thread e existe pelo menos um caminho livre de definição que relaciona a definição e uso. Utilizado no critério Todos-usos do teste estrutural sequencial.

• associação sinc-uso - Há uma definição e um sinc-uso de uma variável compartilhada no thread e existe pelo menos um caminho livre de definição que relaciona a definição e uso.

• associação com-c-uso - Há uma definição e um com-c-uso de uma variável compartilhada no thread.

• associação com-p-uso - Há uma definição e um com-p-uso de uma variável compartilhada no thread.

Além das definições e uso de variáveis, no teste de programas concorrentes é desejado testar todas as possíveis comunicações, sendo necessário saber o momento em que elas ocorreram. Sarmanho et al. (2008) utilizaram uma relação de

causalidade (LAMPORT, 1978), atribuindo timestamps aos eventos de

sincronização. A atribuição de timestamps consiste em atribuir para cada thread um vetor de relógio local, que possui um tamanho igual ao número total de threads. A cada comando que o thread executa, ele atualiza seu vetor na posição correspondente ao thread. Quando um evento de sincronização post ocorre, o timestamp é atualizado e o evento recebe esse timestamp registrando o momento

(32)

que ele acontecendo. Quando um evento wait ocorre o timestamp é atualizado com base no timestamp do post que sincronizou e atualiza na posição correspondente do thread. Um exemplo da aplicação do timestamp é apresentado na Figura 11.

Figura 11 – Exemplo de aplicação do timestamp.

Fonte: Sarmanho et al. (2008).

Com base no grafo e nas informações de comunicação, Sarmanho et al. (2008) definiram dois grupos de critérios estruturais. Foram definidos cinco critérios baseados em fluxo de controle e sincronização:

• Todos-p-nós - Todos os nós com primitiva post precisam ser executados ao menos uma vez pelo conjunto de casos de teste.

• Todos-w-nós - Todos os nós com primitiva wait precisam ser executados ao menos uma vez pelo conjunto de casos de teste.

• Todos-nós - Todos os nós precisam ser executados ao menos uma vez pelo conjunto de casos de teste. Mesmo critério utilizado no teste de programas sequenciais.

• Todas-s-arestas - Todas as arestas de sincronização precisam ser executadas ao menos uma vez pelo conjunto de casos de teste.

• Todas-arestas - Todas as arestas precisam ser executadas ao menos uma vez pelo conjunto de casos de teste. Mesmo critério utilizado no teste de programas sequenciais.

(33)

Sete critérios baseados em fluxo de dados e comunicação foram propostos:

• Todas-definições-com - Todos os caminhos que cobrem associações com-c-uso ou com-p-com-c-uso com as respectivas definições das variáveis compartilhadas devem ser executados pelo menos uma vez pelo conjunto de casos de teste. • Todas-definições - Todos os caminhos que cobrem associações c-uso, p-uso,

com-c-uso ou com-p-uso com as respectivas definições das variáveis devem ser executados pelo menos uma vez pelo conjunto de casos de teste.

• Todas-com-c-uso - Todos os caminhos que cobrem associações com-c-uso devem ser executados pelo conjunto de casos de teste.

• Todas-com-p-uso - Todos os caminhos que cobrem associações com-p-uso devem ser executados pelo conjunto de casos de teste.

• Todos-c-uso - Todos os caminhos que cobrem associações c-uso devem ser executados pelo conjunto de casos de teste. Mesmo critério utilizado no teste de programas sequenciais.

• Todos-p-uso - Todos os caminhos que cobrem associações p-uso devem ser executados pelo conjunto de casos de teste. Mesmo critério utilizado no teste de programas sequenciais.

• Todos-sinc-uso - Todos os caminhos que cobrem associações sinc-uso devem ser executados pelo conjunto de casos de teste.

Esse modelo e critérios de teste são voltados para programas concorrentes com memória compartilhada voltados para arquitetura MIMD que utilizam a biblioteca PThreads. Apesar de o CUDA utilizar memória compartilhada, o modelo de arquitetura SIMT, similar a SIMD, combinado com as particularidades anteriormente apresentadas impossibilitam a utilização de certos critérios ou dificultam a cobertura de todos os elementos existentes na execução de um programa CUDA. Isso torna necessário um modelo e critérios de teste próprios para o modelo de programação CUDA.

3.3.3.5. Considerações Finais

Neste capítulo foram apresentados os conceitos de GPU, mostrando sua arquitetura, diferenças entre GPU e CPU e suas particularidades. Foi abordado o modelo de programação CUDA utilizado para implementar aplicações voltadas para GPU Nvidia. Um exemplo de código CUDA com defeito foi demonstrada para ilustrar um

(34)

dos possíveis defeitos que podem ser encontrados nessas aplicações. Buscando ilustrar como pode-se melhorar a qualidade de aplicações, foi introduzido o teste de software para programas sequenciais e concorrentes, tendo como foco um modelo e critérios voltados para arquitetura SIMD, devido a similaridade com GPU. Alguns desafios relacionados ao teste dessas aplicações foram apresentados, relacionando-os com o modelo CUDA.

3.3.4. Trabalhos Relacionados

Esta seção apresenta os trabalhos relacionados com o contexto de melhoria de qualidade e confiabilidade de aplicações para GPU. Nessa linha, foram identificados trabalhos voltados para a análise dos mecanismos de tolerância a falhas da GPU, que pode afetar os resultados obtidos na execução da aplicação, e estudos voltados a detecção de defeitos em aplicações CUDA e OpenCL.

Sendo um modelo de programação e arquitetura recente, diversas funcionalidades comumente usadas em HPC não foram inicialmente suportadas por este modelo. Uma dessas funcionalidades é poder parar a execução da aplicação e posteriormente continuar do ponto em que parou. Visando isso, Takizawa et al.(2009) propuseram o CheCUDA, uma ferramenta CPR (Checkpoint / Restart). Ele busca permitir que na ocorrência de uma instabilidade que impeça a continuação da execução da aplicação, que ela possa posteriormente continuar do mesmo ponto. Isso também permite também que quando utilizado em clusters ou Cloud Computing, a aplicação possa ser migrada para um novo nó. Para isso, o CheCUDA armazena informações referente a execução, assim como efetua uma cópia do conteúdo da memória da GPU para a memória da CPU e desabilita o CUDA Runtime, posteriormente reativando-o e recuperando as informações salvas. No entanto, o CheCUDA exige que a aplicação seja desenvolvida usando uma extensão CUDA de baixo nível (CUDA driver API) e que o código seja recompilado para seu funcionamento. Baseado nisso, Nukada, Takizawa e Matsuoka (2011) propuseram o NVCR que permite usar funções de alto nível (CUDA runtime API) e não exige a recompilação do código para que as capacidades CPR funcionem. Laosooksathit, Naksinehaboon e Leangsuksan (2011) exploraram o mesmo problema, buscando assim diminuir o overhead causado pelo CPR na aplicação.

(35)

Apesar da linha de GPU voltada para HPC suportar ECC (Error Correction Code), apresentando maior resiliência a erros, a linha doméstica (Geforce) não apresenta esse recurso, estando dessa forma mais propenso a erros (TANet al., 2011; HAQUE; PANDE, 2010; SHEAFFER; LUEBKE; SKADRON, 2007). Para remediar esse problema Maruyama, Nukada e Matsuoka (2010) propuseram um framework para detectar erros na memória e se recuperar de erros, apresentando, no entanto, um alto overhead. (JEON; ANNAVARAM, 2012) e (ABDEL-MAJEED et al., 2015) buscam minimizar o overhead causado pela detecção e recuperação de erros buscando utilizar recursos subutilizados na execução da aplicação, como, por exemplo, a subutilização causada pela divergência de warps.

Poucos trabalhos relacionados a teste de software buscam diminuir defeitos em aplicações sob o modelo de programação CUDA. Dentre os poucos trabalhos existentes, percebe-se um forte foco dos mesmos na detecção de condições de disputa e, em alguns casos, em buscar deadlocks. Embora ainda incipiente, o teste de programas concorrentes CUDA conta com algumas pesquisas já desenvolvidas. Esta seção apresenta alguns dos principais trabalhos encontrados na literatura e que podem ser relacionados a este assunto.

Boyer, Skadron e Weimer (2018) apresentam uma técnica de análise automática para revelar dois tipos específicos de falha em aplicações CUDA: condição de disputa que pode resultar em uma saída não esperada, e conflitos de banco no acesso à memória compartilhada, que degrada a vazão de dados. A análise e detecção é feita por meio da instrumentação do programa, permitindo rastrear os acessos à memória por diferentes threads e por meio desses dados determinar se houve uma condição de disputa ou conflito de banco. Li e Gopalakrishnan (2010) buscaram atender aos mesmos objetivos apresentando a ferramenta PUG (Prover of User GPU programs), que detecta condições de disputa no acesso à memória, conflitos de banco de dados na memória compartilhada e ainda a presença de deadlocks. Para isso foram utilizadas as Teorias do Módulo de Satisfação (Satisfiability Modulo Theories - SMT) (BARRETT et al., 2009) para analisar automaticamente os kernels de programas CUDA. Também visando condições de disputa em CUDA, Zhenget al. (2011) desenvolveram o GRace, que utiliza análise estática para diminuir o número de instruções que precisam ser instrumentadas, melhorando o desempenho. Ele analisa os registros gerados dinamicamente buscando detectar condições de disputa.

Referências

Documentos relacionados

Um outro sistema robótico do tipo rodas, figura 10, é utilizado para inspecionar e limpar a superfície interna de dutos de concreto (Saenz, Elkmann, Stuerze, Kutzner, &amp;

Desse modo, a escola e a mídia são significadas como um lugar de consolidação de imagens sobre a língua, através do ensino de língua portuguesa, estabilizando-as a

[r]

Em todas as vezes, nossos olhos devem ser fixados, não em uma promessa apenas, mas sobre Ele, o único fundamento da nossa esperança, e em e através de quem sozinho todas as

Quadro de métodos e técnicas utilizados nas pesquisas contemporâneas em Arquitetura e Urbanismo: heranças, concordâncias e diferenças com as Ciências

Mesmo quando um administrador resolve seguir seus próprios critérios e valores na tomada de decisões, ele freqüentemente não sabe como resolver satisfatoriamente suas prioridades

qualitativas ou quantitativas discretas, a tabela de freqüência consiste em listar os valores possíveis da variável, numéricos ou não, e fazer a contagem na tabela de dados brutos

[Informar a data, o nome e a assinatura do dirigente máximo que aprovou o documento Termo de Abertura do Projeto antes deste projeto ser solicitado ao Governador pelo