tÉcnicas de anÁlise e otimizaÇÃo de cÓdigo … · resumo a otimização de implementações de...

54
UNIVERSIDADE ESTADUAL DE PONTA GROSSA SETOR DE CIÊNCIAS AGRÁRIAS E DE TECNOLOGIA DEPARTAMENTO DE INFORMÁTICA BACHARELADO EM INFORMÁTICA TÉCNICAS DE ANÁLISE E OTIMIZAÇÃO DE CÓDIGO EM LINGUAGEM C++ NA IMPLEMENTAÇÃO DO ALGORITMO DE ORDENAÇÃO QUICKSORT PONTA GROSSA SETEMBRO/2014

Upload: truongnhu

Post on 20-Sep-2018

212 views

Category:

Documents


0 download

TRANSCRIPT

UNIVERSIDADE ESTADUAL DE PONTA GROSSA

SETOR DE CIÊNCIAS AGRÁRIAS E DE TECNOLOGIA

DEPARTAMENTO DE INFORMÁTICA

BACHARELADO EM INFORMÁTICA

TÉCNICAS DE ANÁLISE E OTIMIZAÇÃO DE CÓDIGO

EM LINGUAGEM C++ NA IMPLEMENTAÇÃO DO

ALGORITMO DE ORDENAÇÃO QUICKSORT

PONTA GROSSA

SETEMBRO/2014

ANDRÉ FELIPHE DE SOUZA FERREIRA

NICOLAS DANIEL ENGELS

TÉCNICAS DE ANÁLISE E OTIMIZAÇÃO DE CÓDIGO

EM LINGUAGEM C++ NA IMPLEMENTAÇÃO DO

ALGORITMO DE ORDENAÇÃO QUICKSORT

Monografia apresentada para disciplina de

Trabalho de Conclusão de Curso da

Universidade Estadual de Ponta Grossa como

exigência parcial para obtenção de título de

graduado em Bacharelado em Informática.

Orientadora: Profª. Drª. Leila Maria Vriesmann

PONTA GROSSA

SETEMBRO/2014

RESUMO

A otimização de implementações de algoritmos pode ser muito útil em programas que

exijam uma maior eficiência, principalmente quando se trata de programas complexos, longos

ou que contenham muitos dados. O presente trabalho tratou de uma parte conceitual sobre as

técnicas para análise e otimização na linguagem C++, e retratou a aplicação de algumas delas

em um código-fonte do algoritmo de ordenação Quicksort, de Schildt (1996), adaptado para a

linguagem C++. As técnicas de otimização trabalhadas foram: generalização de algoritmos

com templates, passagem de argumentos por referência, declaração de variáveis no escopo de

utilização, inicialização de variáveis, o uso da recursão, funções qualificadas com a diretiva

inline, semântica de movimento e execução de tarefas concorrentemente com o uso de

threads. Os dados ordenados foram de três tipos: um nativo da linguagem (int); um definido

pelas bibliotecas do padrão para manipulação de textos (std::string) e uma classe própria

(TCC::Pessoa). Esta classe própria, com a finalidade de demonstrar o impacto de diferentes

complexidades de objetos, representa pessoas com atributos: nome, sexo, peso e endereço.

Todas as técnicas demonstraram um aumento ou na performance do algoritmo ou na

produtividade do programador. Nesse caso a técnica mais eficiente em termos de desempenho

foi o uso de threads que utiliza o potencial de processamento paralelo do processador

dobrando a performance da implementação do algoritmo na plataforma mensurada. Desses

resultados, pode-se concluir que o trabalho constitui um campo significativo para estudo, uma

vez que pode auxiliar programadores na busca sistemática de obter códigos otimizados.

Palavras-chave: Análise e otimização de código, C++, técnicas de otimização de código,

implementação Quicksort, desempenho de aplicativos.

LISTA DE FIGURAS

Figura 1 – Exemplo do algoritmo Quicksort em um conjunto de inteiros. ........................................... 13

Figura 2 – Ilustração de como a semântica do movimento age no objeto. .......................................... 16

Figura 3 – Média aritmética. ................................................................................................................. 23

Figura 4 – Desvio padrão. ...................................................................................................................... 23

Figura 5 – Coeficiente de variação. ....................................................................................................... 23

LISTA DE CÓDIGOS-FONTE

Código-fonte 1 – Implementação do Quicksort para ordenar um conjunto de caracteres. ................. 18

Código-fonte 2 – Classe própria com exemplo real para análise. ......................................................... 19

Código-fonte 3 – Geração dos dados usados no algoritmo. ................................................................. 20

Código-fonte 4 – Utilização da biblioteca chrono para mensuração de tempo. .................................. 22

Código-fonte 5 – Quicksort para ordenar inteiros. ............................................................................... 25

Código-fonte 6 – Quicksort para ordenar strings. ................................................................................. 25

Código-fonte 7 – Quicksort para ordenar pessoas................................................................................ 26

Código-fonte 8 – Versão 2 genérica do Quicksort. ............................................................................... 28

Código-fonte 9 – Exemplos de uso da ordenação genérica. ................................................................. 29

Código-fonte 10 – Versão 3 com o uso de referências. ........................................................................ 32

Código-fonte 11 – Alteração no código cliente da função para passagem de referências. .................. 32

Código-fonte 12 – Versão 4 do Quicksort usando a declaração de variáveis no escopo de utilização. 35

Código-fonte 13 – Versão 5 com a quarta técnica de inicialização aplicada. ....................................... 36

Código-fonte 14 – Versão 6 iterativa do Quicksort. .............................................................................. 39

Código-fonte 15 – Versão 7 do algoritmo Quicksort. ........................................................................... 42

Código-fonte 16 – Alterações nas funções membro do tipo para representação de pessoas. ............ 42

Código-fonte 17 – Versão 8 do algoritmo Quicksort usando a semântica de movimento. .................. 45

Código-fonte 18 – Alterações para permitir a semântica de movimento. ........................................... 46

Código-fonte 19 – Versão com o uso de concorrência do algoritmo de ordenação Quicksort. ........... 49

LISTA DE TABELAS

Tabela 1 – Dados da execução da versão inicial do Quicksort. ............................................................. 26

Tabela 2 – Dados da execução da versão genérica do Quicksort. ........................................................ 30

Tabela 3 – Dados da execução da versão utilizando passagem de referência. .................................... 32

Tabela 4 – Dados da execução na versão utilizando a declaração no escopo de utilização. ................ 35

Tabela 5 – Versão 5 com os dados de tempo de execução. ................................................................. 37

Tabela 6 – Dados da execução da versão iterativa. .............................................................................. 40

Tabela 7 – Dados da execução da versão utilizando a expansão da função. ........................................ 43

Tabela 8 – Dados da execução da versão com movimento. ................................................................. 46

Tabela 9 – Dados da execução com threads. ........................................................................................ 50

LISTA DE GRÁFICOS

Gráfico 1 – Relação entre as execuções das versões do algoritmo Quicksort. ..................................... 30

Gráfico 2 – Relação entre as execuções do Quicksort. ......................................................................... 33

Gráfico 3 - Relação das melhorias entre técnicas. ................................................................................ 37

Gráfico 4 – Proporção da melhoria entre as versões do algoritmo. ..................................................... 40

Gráfico 5 – Comparações entre versões. .............................................................................................. 43

Gráfico 6 – Relação de execuções das técnicas. ................................................................................... 47

Gráfico 7 - Comparação entre a primeira e última versão do algoritmo. ............................................. 51

Gráfico 8 - Comparação do impacto no desempenho de todas as técnicas. ........................................ 53

SUMÁRIO

1 INTRODUÇÃO ....................................................................................................................................... 9

1.1 PROBLEMÁTICA ............................................................................................................................. 9

1.2 JUSTIFICATIVA ............................................................................................................................. 10

1.3 OBJETIVO ..................................................................................................................................... 10

2 REVISÃO BIBLIOGRÁFICA .................................................................................................................... 12

2.1 PESQUISAS NA ÁREA ................................................................................................................... 12

2.2 QUICKSORT .................................................................................................................................. 12

2.3 TÉCNICAS DE OTIMIZAÇÃO ......................................................................................................... 13

3 METODOLOGIA ................................................................................................................................... 17

3.1 PLATAFORMA .............................................................................................................................. 17

3.2 VERSÃO QUICKSORT INICIAL ....................................................................................................... 17

3.3 GERAÇÃO DE AMOSTRAS ............................................................................................................ 18

3.4 MENSURAÇÃO DE TEMPO ........................................................................................................... 21

4 RESULTADOS E DISCUSSÕES ............................................................................................................... 24

4.1 IMPLEMENTAÇÃO INICIAL ........................................................................................................... 24

4.2 PRIMEIRA TÉCNICA: GENERALIZAÇÃO COM TEMPLATES ........................................................... 27

4.3 SEGUNDA TÉCNICA: PASSAGEM POR REFERÊNCIA ..................................................................... 31

4.4 TERCEIRA TÉCNICA: UTILIZAÇÃO DA VARIÁVEL EM ESCOPO DE UTILIZAÇÃO ............................ 34

4.5 QUARTA TÉCNICA: INICIALIZAÇÃO DE VARIÁVEIS ....................................................................... 35

4.6 QUINTA TÉCNICA: O USO DA RECURSÃO .................................................................................... 38

4.7 SEXTA TÉCNICA: FUNÇÕES QUALIFICADAS COM A DIRETIVA INLINE ......................................... 41

4.8 SÉTIMA TÉCNICA: SEMÂNTICA DE MOVIMENTO ........................................................................ 44

4.9 OITAVA TÉCNICA: EXECUTAR TAREFAS CONCORRENTEMENTE COM O USO DE THREADS ........ 47

5 CONSIDERAÇÕES FINAIS ..................................................................................................................... 52

6 REFERÊNCIA BIBLIOGRÁFICA .............................................................................................................. 54

9

1 INTRODUÇÃO

1.1 PROBLEMÁTICA

O melhor uso dos recursos computacionais disponíveis para o software, que pode se

chamar de eficiência, é importante no desenvolvimento de aplicativos para a sociedade

contemporânea. Cada vez mais, com sistemas complexos interconectados e com volumes de

dados maiores, o desempenho de softwares apresenta-se como um limitador a ser melhorado.

Mesmo que os processadores evoluam conforme a lei de Moore, que prevê o dobro de

velocidade a cada dezoito meses, escrever código eficiente é necessário (BULKA,

MAYHEW, 1999; p. XI).

Em aplicações onde a clareza do paradigma de orientação a objeto deve estar aliada

com a performance, a linguagem C++ é uma das ideais (STROUSTRUP, 2013; p. XII). Como

herda vários pontos de desempenho da linguagem em que foi concebida, o C, a linguagem

tem um bom desempenho, além de ter as melhorias do paradigma na questão da reutilização e

organização do código (STROUSTRUP, 2013, p. 30; ECKEL, 2000; p. 25).

Segundo Schildt (1996, p. 6), “C é tratada como linguagem de médio nível porque

combina elementos de alto nível com a funcionalidade da linguagem assembly”, então isso

proporciona ao desenvolvedor a abstração da linguagem estruturada e a granularidade de

linguagem de máquina. Como C++ evoluiu de C seus conceitos e evoluções, também utilizam

desta premissa (ECKEL, 2000; p. 72).

Muitos desenvolvedores que migraram do C para o C++ acabaram tendo a impressão

de que C++ era uma linguagem com menor desempenho e eficiência. (ECKEL, 2000; p. 72).

O problema apontado por Bulka e Mayhew é que escrever código eficiente em C++ requer

habilidades específicas para isto, porque é uma linguagem que suporta vários estilos de

programação como estruturada, orientada a objetos, genérica e funcional (STROUSTRUP,

2013; p. 11), e assim foram adicionados muitos mecanismos nativos.

Existem muitos pontos no C++ que o compilador insere instruções para realizar as

funcionalidades, o que em C não ocorre frequentemente já que a mesma é mais análoga ao

código de máquina gerado. Desta maneira, o programador C++ pode não perceber estes

pontos, e com isso não há como escrever software eficiente ao acaso, é necessário conhecer as

nuances da linguagem e codificação para ter um desempenho similar à linguagem C, além de

mais clareza e produtividade (BULKA, MAYHEW, 1999; p. VIII). Visto os pontos

levantados referentes à escrita de código eficaz em C++, chega-se ao tema da pesquisa:

10

Quais são as técnicas, estratégias e boas práticas no trabalho diário do programador de

C++ para que seu código funcione eficientemente sem ter um grande impacto na lógica

utilizada no algoritmo?

1.2 JUSTIFICATIVA

A análise e otimização de algoritmos são as ferramentas que se propõem a detectar e

corrigir os problemas de eficiência. No entanto, para ter um programa com desempenho

satisfatório é necessário que a linguagem utilizada para implementação possibilite, pois, é

através desta que ocorre a execução do algoritmo propriamente dito.

A linguagem de programação C++ permite tanto a construção de softwares complexos

(como linguagem de alto nível), como também ao controle que o programador pode ter nos

recursos do sistema (similar a uma linguagem de baixo nível). Assim, para que o código-fonte

seja escrito eficientemente, muitas vezes, é necessário que o programador conheça as

armadilhas de eficiência. Isto é, os pontos onde o compilador insere operações onerosas para

realizar o que o programador deseja, sendo que existem maneiras de manter o sentido do

código sem gerar essas operações. Contudo, a linguagem por si só não garante o desempenho,

sendo necessária a correta modelagem do algoritmo.

1.3 OBJETIVO

Otimizações de programas são úteis, tanto para sistemas complexos e distribuídos que

devem processar uma quantidade imensa de dados, quanto para sistemas embarcados que

devem fazer o melhor uso da quantidade de recursos. A performance define como o usuário

percebe a aplicação e tem relação direta com a produtividade de quem a usa.

O objetivo é introduzir conceitos de otimização e mensurar o tempo de execução para

constatar se determinada estratégia é realmente eficaz. Destarte, demonstrar o processo de

otimização na implementação do algoritmo e evidenciar os pontos que podem ser melhorados.

Além de introduzir técnicas com os conceitos recentemente adicionados no ano de 2011, da

especificação padronizada da linguagem C++. Ao analisarem-se as técnicas e diretivas de

otimização em C++, é possível escrever códigos de melhor qualidade, eficientes e simples. A

ideia é que as otimizações sejam escritas na implementação do código, caracterizando assim

uma boa prática onde não exista a necessidade de rever o código-fonte.

Desta forma o fluxo da pesquisa segue para a seção 2, que apresenta a revisão

bibliográfica do tema, elencando as estratégias citadas pelos autores em tópicos, com seu

embasamento teórico, em conjunto com as justificativas a serem consideradas como pontos de

11

verificação na busca de otimizações. Na seção 3, são demonstradas as metodologias

empregadas e convenções utilizadas, disponibilizando todas as informações que são

necessárias para obtenção do objetivo. A seção 4 apresenta as análises e discussões das

aplicações das técnicas do trabalho. Finalmente, a seção 5 conclui o trabalho com a análise

sintética dos dados gerados pelo trabalho e últimos comentários.

12

2 REVISÃO BIBLIOGRÁFICA

2.1 PESQUISAS NA ÁREA

A obra de Bulka e Mayhew (1999) é um dos livros mais completos quando o assunto é

otimização, onde podem ser consultadas várias estratégias. Porém, é necessário utilizar

também os novos conceitos ao padrão C++ na ISO/IEC 14882-2011, chamada C++11,

explanada na obra do criador da linguagem Stroustrup (2013), bem como outras técnicas

baseadas em experimentações (ISENSEE, 2009). O foco será em técnicas que não tem

impacto na clareza e passos do algoritmo, são boas práticas que devem fazer parte do

conhecimento do programador em C++ que serão mensuradas e analisadas.

Conforme Fog (1998; p. 16) antes de aperfeiçoar qualquer trecho de código é

necessário identificar as partes críticas do programa, as partes mais utilizadas como laços e

computações. Deve-se antes analisar, mensurar e identificar os pontos críticos. Isensee (2009)

concorda com este procedimento, mas vai além, afirma que a otimização em todos os pontos

prejudica a clareza do código, pois é melhor um aplicativo lento que funcione do que um

rápido que trave devido a erros gerados pela falta de clareza no código, que prejudicam a

manutenção.

2.2 QUICKSORT

No momento de escolha de um algoritmo para fazer aplicação das técnicas, analisar e

tirar conclusões deve-se optar por um bom exemplo de ponto crítico. Pensando nisso, o que

vem em mente, é o algoritmo de ordenação, porque este tem alto uso nas mais diversas

aplicações. Como escreveu Schildt (1996; p. 501) “[...] Essas rotinas [ordenação e pesquisa]

são utilizadas em praticamente todos os programas de banco de dados, bem como em

compiladores, interpretadores e sistemas operacionais”. Conforme Stroustrup (2013; p. 1229)

ordenar é fundamental.

Segundo Skiena (2009), o algoritmo Quicksort foi desenvolvido por Tony Hoare em

1980 e é um dos melhores algoritmos de ordenação atualmente. Ele consiste na ideia de

partição onde é escolhido um elemento do conjunto que será ordenado, chamado de pivô. A

partir desse elemento são criados dois subconjuntos em relação ao pivô: um com os elementos

menores e outro com os elementos maiores ou iguais. Esse processo de partição é repetido

com cada subconjunto até que os elementos estejam todos ordenados crescentemente.

13

Figura 1 – Exemplo do algoritmo Quicksort em um conjunto de inteiros.

Como se pode ver na figura 1 o algoritmo Quicksort em um conjunto de inteiros, a

transição de cada nível é um processo completo do algoritmo. O pivô nesse caso é sempre o

primeiro elemento que fica no meio dos subconjuntos menor e maior, denotado pela cor

vermelha. A partir disso em cada subconjunto o processo é repetido até que ao final temos os

elementos ordenados em ordem crescente.

2.3 TÉCNICAS DE OTIMIZAÇÃO

Os tópicos enumerados com as técnicas e estratégias levantadas para a análise estão

elencados abaixo. Logo após o embasamento de cada um deles:

1. Generalização de algoritmos com templates.

2. Passagem de argumentos por referência.

3. Declaração de variáveis no escopo de utilização.

4. Inicialização de variáveis.

5. O uso da recursão.

6. Funções qualificadas com a diretiva inline.

7. Semântica de movimento.

8. Executar tarefas concorrentemente com o uso de threads.

1. Generalização de algoritmos com templates: os templates são uma grande

funcionalidade adicionada ao C++ que permitem o uso do paradigma genérico no código, pois

quando é usado templates se expressa os algoritmos em termos de conceitos (STROUSTRUP,

2013; p. 731). Sem especificar o tipo de dado manipulado no código podem-se escrever

genericamente fontes com alto reuso, como no caso da ordenação onde o tipo de dado

ordenado não muda o comportamento do algoritmo. Assim se implementada a técnica de

generalizar pode-se utilizar a mesma função para ordenar um conjunto de qualquer tipo de

14

dado como: inteiro, ponto flutuante, caracteres e até mesmo tipos complexos definidos pelo

programador (ECKEL, 2000; p. 723).

Teoricamente, parametrizando a versão de um algoritmo usando templates, a

performance não deve ser afetada, pois os mesmos são instanciados em tempo de compilação,

em que o compilador gera uma versão do algoritmo para cada template parametrizado. Desta

forma, apenas o tempo de compilação é afetado, o que é um custo relativamente baixo, sendo

que o tempo de execução é algo mais sensível para o usuário (BULKA, MAYHEW, 1999; p.

28).

2. Passagem por referência ou por valor: passar um argumento por valor a uma função

requer que o mesmo seja copiado, chamando o construtor de cópia, enquanto que passar por

referência não invoca a cópia, pois apenas o endereço é passado (ISENSEE, 2009).

O único cuidado ao passar por referência é que o argumento, se alterado, altera o

objeto e não uma cópia, mas isso pode ser facilmente restrito se explícito que o parâmetro

passado é somente leitura com o qualificador const, garantindo assim ao usuário da função

que o argumento não é alterado (STROUSTRUP, 2013, p. 45; SCHILDT, 1998; p. 23).

3. Declaração de variáveis no escopo de utilização: em C, Pascal e outras linguagens

populares, as variáveis devem ser declaradas no início do escopo do código. Este hábito pode

ser usado incorretamente em C++, já que quando é declarada uma variável tem-se a execução

de seu construtor, então se a variável não é utilizada por algum motivo (como uma condição

de retorno) temos o uso do processador sem necessidade (BULKA, MAYHEM, 1999; p. 20).

4. Inicialização de variáveis: segundo Isensee (2009), outro legado do

desenvolvimento em C é que as variáveis precisam ser declaradas e depois devem ser

inicializadas. Com o C++ isso não se aplica, pois é até mesmo vantajoso declarar e iniciar o

objeto de uma vez só. Ao fazer isto é invocado apenas o construtor de cópia. Definir e atribuir

separadamente chama tanto o construtor padrão quanto o operador de atribuição, tornando a

primeira operação trivial.

5. O uso da recursão: funções recursivas são usadas quando um problema pode ser

expresso em termos de si mesmo. Porém existem problemas que o uso da recursão acarreta: o

gasto adicional de repetidas chamadas a funções, pois é necessário gravar o contexto do

programa na pilha a cada chamada de função (SCHILDT; 1996); se a função recursiva for mal

programada, a mesma pode ficar desenfreada, consumindo rapidamente toda a memória da

pilha do programa; funções recursivas não podem ser inline, que é explanada

posteriormente, porque o compilador não consegue realizar as otimizações mais profundas

(BULKA, MAYHEW, 1999; p. 89).

15

Logo, é preciso uma análise se não é possível representar o algoritmo em sua forma

iterativa ao invés da recursiva. A versão iterativa é preferível caso não haja perda no design

do algoritmo, consequentemente pode ser qualificada como inline e ter um aumento

significativo na performance da função.

6. Funções qualificadas com a diretiva inline: as funções qualificadas com a palavra

reservada inline funcionam como qualquer outro método em C++, a diferença ocorre onde a

função é chamada sendo substituída pelo corpo da função na compilação (BULKA,

MAYHEW, 1999; p. 66). Assim, há a vantagem de não invocar o método no código que usa a

função, consequentemente o compilador pode usar uma otimização mais agressiva

(STROUSTRUP, 2013; p. 307), pois conhece o corpo da função. Como ao chamar uma

função é inserida mais instruções para salvar o estado dos registradores, também há o ganho

em que não precisa ser feita, graças à expansão do inline (ISENSEE, 2009).

Existem controvérsias em deixar função inline, como ela substitui o corpo da função

pode ser que haja aumento do executável se a função é chamada em vários pontos. Outra

controvérsia é que o qualificador (inline) é opcional ao compilador, ele pode desconsiderar e

não realizar o inline, se a função for extensa, complexa demais ou recursiva (ECKEL, 200;

p. 414).

Conforme a obra de Bulka e Mayhew (1999; p. 66) realizar o inline de funções não

muda o design do algoritmo, independentemente se o algoritmo é ótimo ou não, terá um

melhor desempenho. Se a função é declarada e definida, o compilador também pode deixar a

função inline sem necessariamente estar com essa qualificação. (SCHILDT, 1998; p. 306).

As funções anônimas (Lambdas) inseridas no padrão novo também são inline por padrão

(STROUSTRUP, 2013; p. 294).

Então em projetos onde o padrão é separar a declaração de funções em cabeçalhos se

faz necessário que o programador tome o cuidado para marcar a função como inline e

defina para que através do cabeçalho o compilador encontre o corpo da função. (ISENSEE,

2009). Essa é uma técnica que pode ser empregada no cotidiano do programador com pouca

perda de produtividade. Somente, se deve analisar com cuidado na frequência de uso e

complexidade da função.

7. Semântica de movimento: a semântica de movimento agregada na nova referência

do C++11 permite “pular” o uso de cópias e objetos temporários, e é uma das mais

importantes funcionalidades adicionadas quanto ao desempenho (JOSUTTIS, 2012; p. 19).

16

A semântica de movimento permite que o compilador mova objetos para outras

localidades na memória, substituindo a criação de objetos temporários copiados na transição.

Ao passar um objeto à função ou retorná-lo, o compilador verifica se naquele escopo o objeto

não será mais utilizado (pois o objeto ficará inválido) e ao invés de copiá-lo, ele pode movê-lo

com um custo muito menor, conforme se vê na figura 2 (STROUSTUP, 2013, p. 317;

JOSUTTIS, 2012, p. 21).

Figura 2 – Ilustração de como a semântica do movimento age no objeto.

8. Executar tarefas concorrentemente com o uso de threads: a concorrência nos

computadores é o ato de executar várias tarefas ao mesmo tempo. Usado para aumentar o

processamento do computador, através de vários processadores e/ou núcleos em cada

processador. É bastante utilizado para dar retorno ao usuário quando é feito um

processamento pesado (STROUSTRUP, 2013; p. 1210). Apesar da indeterminação sequencial

que as tarefas serão executadas, isto é, em pontos onde há mais de uma thread acessando o

mesmo recurso, não há garantias de quais threads acessarão primeiro, devendo ser inseridos

controles e cuidados na programação para garantir o sincronismo.

Como nota Willians (2012), por muito tempo a linguagem não comportou o conceito

de concorrência, sendo que os desenvolvedores tinham que recorrer a bibliotecas específicas

para cada plataforma. Assim, era necessário utilizar bibliotecas não portáteis e as bibliotecas

padrões definidas na implementação da linguagem não proviam nenhuma garantia de serem

consistentes ao serem usadas por mais de uma thread ao mesmo tempo. Porém, na nova

especificação da linguagem feita em 2011, C++11, o conceito foi incorporado e adicionado

nas bibliotecas da linguagem.

Baseando-se nas técnicas e estratégias de otimização expostas, foi definida uma

metodologia, a qual será apresentada na próxima seção.

17

3 METODOLOGIA

3.1 PLATAFORMA

As ferramentas de compilação utilizadas foram as disponibilizadas pela GNU

Compiler Collection (https://gcc.gnu.org/) usando especificamente o conjunto ferramentas de

compilação de fontes C++: Pré-processador GNU C preprocessor 4.8.1; Compilador G++

4.8.1; Montador GNU Assembler 2.23.52 e vinculador GNU Linker 2.23.52, que são

softwares livres. O sistema operacional utilizado será o Windows 7 Professional. Para usar as

ferramentas da GNU no Windows é utilizada a portabilidade do MinGW

(http://www.mingw.org/). Isto é, os códigos-fontes usados no trabalho podem ser compilados

em qualquer plataforma que possua um compilador que implemente o padrão C++ ISO/IEC

14882-2011. As mensurações serão rodadas em um processador Intel Pentium Dual-Core

T4500 com um clock de 2,3 GHz com 4 GB de memória RAM. Apesar de ser utilizada

plataforma de software e hardware específicos, os conceitos apresentados servem de base toda

a diversidade de plataformas.

3.2 VERSÃO QUICKSORT INICIAL

O algoritmo Quicksort estudado será em C, encontrada na obra de Schildt (1996, p.

514). Cada estratégia ou método explanado na seção 2 será aplicado na implementação do

algoritmo, e o resultado analisado contra a implementação anterior, gerando comparativos

entre versões com as otimizações. A versão inicial é recursiva, para a ordenação de um

conjunto de caracteres, apresentada no código-fonte 1.

1 void quick(char *item, int count)

2 {

3 qs(item, 0, count - 1);

4 }

5

6 void qs(char *item, int left, int right)

7 {

8 register int i, j;

9 char x, y;

10

11 i = left; j = right;

12 x = item[(left + right)/2];

13

14 do {

18

15 while(item[i]<x && i<right) i++;

16 while(x<item[j] && j>left) j--;

17

18 if(i<=j) {

19 y = item[i];

20 item[i] = item[j];

21 item[j] = y;

22 i++; j--;

23 }

24 } while(i<=j);

25

26 if(left<j) qs(item, left, j);

27 if(i<right) qs(item, i, right);

28 }

Código-fonte 1 – Implementação do Quicksort para ordenar um conjunto de caracteres.

A implantação do algoritmo é bem simples. Como podemos ver no código-fonte 1, a

chamada é feita utilizando a função quick definida na linha 1. A função recebe o ponteiro

*item que aponta para o início do conjunto de char de tamanho count. A função qs é

chamada e nela é realizada a ordenação por recursividade, pela linha 6 podemos ver que ela

recebe o ponteiro *item, o limite inferior left e o limite superior right. Na linha 12

podemos ver a escolha do pivô x, como o elemento central do arranjo. Da linha 14 até a 24 é

realizado o particionamento do conjunto, isto é, os elementos são separados em dois

subconjuntos controlados pelas variáveis i e j, por comparação ao pivô (linha 15 e 16). Então

da variável left à i fica o subconjunto menor que o pivô, e da variável j a right fica o

subconjunto maior. É fácil ver que se i igual a right é sinal que não existe o subconjunto

maior, e se j é igual a left não existe o menor. E as chamadas nas linhas 26 e 27, que

iniciam o processo novamente, apenas verificando se existem elementos no subconjunto para

serem particionados.

3.3 GERAÇÃO DE AMOSTRAS

Os dados a serem ordenados nas implementações desse trabalho serão de três tipos

comumente usados nas mais diversas aplicações: um nativo da linguagem (int); um definido

pelas bibliotecas do padrão para manipulação de textos (std::string) e uma classe própria

com a finalidade de demonstrar o impacto de diferentes complexidades de objetos

(TCC::Pessoa). A classe própria é um exemplo para representação de pessoas, com atributos

de nome, sexo, peso e endereço (código-fonte 2).

19

1 #include <string>

2

3 namespace TCC {

4

5 class Pessoa

6 {

7 public: enum class tipo_sexo { masculino, feminino };

8 private:

9 std::string nome;

10 Pessoa::tipo_sexo sexo;

11 float peso;

12 std::string endereco;

13

14 public:

15 explicit Pessoa(const std::string nome,

16 const Pessoa::tipo_sexo sexo, const float peso,

17 const std::string endereco)

18 : nome(nome)

19 , sexo(sexo)

20 , peso(peso)

21 , endereco(endereco) {}

22

23 std::string valorNome() const { return this->nome; }

24 Pessoa::tipo_sexo valorSexo() const { return this->sexo; }

25 float valorPeso() const { return this->peso; }

26 std::string valorEndereco() const { return this->endereco; }

27 };

28 }

Código-fonte 2 – Classe própria com exemplo real para análise.

A declaração dos atributos da classe ocorre na linha 9 até a 12. Por motivo de

simplicidade foi declarado apenas o construtor para definir os atributos na linha 15, ele foi

qualificado como explicit para evitar que o compilador realize conversões automáticas que

são difíceis de perceber. Assim, uma boa prática de qualidade de código é codificar

construtores com o qualificador explicit, para que o programador tome cuidado com a

clareza do código. (ISENSEE, 2009). Estão implementadas apenas as funções de retirada da

informação da classe listadas nas linhas 23 a 26, todas as funções estão com o modificador

const dando a garantia de não alterar o objeto que pertencem.

20

O volume de dados para serem ordenados será único, com uma quantidade

considerável para que as diferenças fiquem evidentes. Em testes na plataforma especificada

uma possível quantidade em que pode ser evidenciada a performance do código, encontra-se

em conjuntos de tamanho de 3.000.000 elementos (3MB). Ao medir o tempo de execução do

código não será considerado o tempo de alocação e inicialização deste espaço, sendo

mensurado apenas o processo de ordenação. Os dados inseridos nos conjuntos não terão

relação nenhuma entre si, serão aleatórios gerados pela utilização da biblioteca random. Como

podemos notar no código fonte 3.

1 #include <random>

2 #include <limits>

3

4 int main()

5 {

6 // Tamanho dos conjuntos 3 MB

7 constexpr auto TAMANHO = 3 * 1000 * 1000;

8

9 // Alocação dos dados

10 int *dados_int = new int[TAMANHO];

11

12 // Configuração da geração de números pseudo-aleatórios

13 auto distribuicao_aleatoria = std::uniform_int_distribution<int> {

14 std::numeric_limits<int>::min(),

15 std::numeric_limits<int>::max()

16 };

17 std::default_random_engine gerador;

18

19 // Popula o conjunto

20 for (auto i = TAMANHO; i > 0; --i)

21 dados_int[i - 1] = distribuicao_aleatoria(gerador);

22

23 // ... Inicia a contagem de tempo

24 // ... Código testado

25 // ... Finaliza a contagem de tempo

26

27 // Libera a memória

28 delete []dados_int;

29 }

Código-fonte 3 – Geração dos dados usados no algoritmo.

21

Na linha 7 temos a variável TAMANHO que possui a dimensão representando o conjunto

utilizado. Ela está na maneira do novo padrão de expressar valores que são definidos

diretamente no código, qualificada com o modificador constexpr por ser uma constante

conhecida em tempo de compilação, ao invés do método antigo de definição por macro. Não

há perda em eficiência nesse método, pois semelhante a macro, o nome é substituído onde é

utilizado pelo compilador além de existir a vantagem da expressão ser calculada em tempo de

compilação. (STROUSTRUP, 2013; p. 266).

A alocação dos dados ocorre na linha 10, para isso é utilizada a memória dinâmica,

pois o valor é alto e a pilha não comporta tamanha alocação. (SCHILDT, 1998; p. 129).

Depois de alocada precisamos inserir os dados que serão ordenados: o laço que inicia o

conjunto está na linha 20 e 21, este utiliza a variável distribuicao_aleatoria(gerador)

para gerar os números aleatórios. A variável distribuição_aleatorio é definida na linha

13, para criar uma distribuição uniforme dos números gerados

std::uniform_int_distribution<int>, implementada pela biblioteca random (linha 2),

que necessita de um intervalo e um objeto para geração de aleatoriedade. Esta gera números

pseudoaleatórios, os quais possuem probabilidades iguais de serem escolhidos. O intervalo é

definido na linha 14 e 15 usando as funções da std::numeric_limits que define os valores

mínimos e o máximos para os tipos fundamentais do C++, incluído na biblioteca limits na

linha 3. Como o vetor é do tipo int, foi escolhido todo o intervalo que uma variável pode

comportar. O objeto definido para geração da aleatoriedade da distribuição é o gerador da

classe std::default_random_engine()(linha 17), esta classe provê um gerador eficaz

implementado pela biblioteca. Por fim depois de utilizada a memória, a mesma é liberada na

linha 28.

3.4 MENSURAÇÃO DE TEMPO

Após obterem-se os dados alocados e inicializados, pode-se medir o tempo de

execução. Segundo Stroustrup (2013; p. 123) para mensurar tempos de execução é

recomendável utilizar a nova biblioteca chrono, adicionada na última versão da linguagem. O

método para medir o tempo de execução estará diretamente no código e, portanto podemos ter

o tempo exato (código-fonte 4).

22

1 // ... includes

2 #include <chrono>

3

4 // ...

5 auto inicio = std::chrono::high_resolution_clock::now();

6

7 // Código a ser mensurado o tempo de execução

8

9 auto fim = std::chrono::high_resolution_clock::now();

10

11 // Retorna o tempo corrido em millisegundos

12 auto tempo_corrido = std::chrono::duration_cast

13 <std::chrono::milliseconds> (fim - inicio).count();

Código-fonte 4 – Utilização da biblioteca chrono para mensuração de tempo.

As funcionalidades utilizadas da biblioteca chrono (incluída na linha 2) são

demonstradas nas linhas 5, 9 e 12; ficam no namespace std::chrono que define, pelo

documento de especificação da linguagem C++11, três relógios para medir o tempo:

system_clock, steady_clock e high_resolution_clock. Stroustrup (2013) indica a

utilidade de cada um dos relógios: system_clock é o de tempo real do sistema, ele pode

avançar no tempo como retroceder, para ficar consistente com outros relógios de hardware;

steady_clock é definido para apenas avançar no tempo e ter diferença constante entre

chamadas; high_resolution_clock é similar em avançar, porém possui mais precisão que o

steady_clock.

Sendo o high_resolution_clock melhor granularidade, então este será utilizado, a

função estática provida por ele: now() retorna o std::chrono::time_point, o tipo de

representação de data/hora, correspondente ao instante de agora. A palavra reservada auto

utilizada nas linhas 5, 9 e 12 teve a funcionalidade alterada pela última especificação da

linguagem, deixando ao compilador a responsabilidade de deduzir o tipo de dado das

variáveis, pela sua inicialização, permitindo maior flexibilidade ao programador. Definindo

assim as variáveis inicio e fim com o tipo std::chrono::time_point e tempo_corrido

com um tipo inteiro que representa os milissegundos: std::chrono::duration::Rep. Tendo

o tempo exato do momento do início da mensuração com o final, através da diferença destes

dois valores tem-se o tempo de execução da implementação do algoritmo. Valor retornado

pelo objeto std::chrono::duration gerado pela função std::chrono::duration_cast

parametrizada para converter a diferença entre o fim e o inicio em milissegundos

(std::chrono::milliseconds).

23

A unidade de medida utilizada na mensuração do tempo será em milissegundos, a

biblioteca provê granularidades menores de tempo (microssegundos, nano-segundos), porém

não são portáteis entre compiladores que implementam o padrão e podem não ser tão exatas.

(KORMAYOS, 2013; p. 293). (STROUSTRUP, 2013; p. 1016). Através dos milissegundos

temos uma boa noção de como melhora o algoritmo em cada técnica. (BULKA, MAYHEW.

1999; p. 5). Gerando amostragens, estimativas de tempo, para depois serem feitos os gráficos

e a análise dos resultados.

A estatística será gerada em uma amostragem com 50 tempos de execução, sendo

esperada uma distribuição normal. Assim, será feita uma média aritmética que é o principal

indicador e produto da análise, juntamente com cálculo do desvio padrão que indica a

dispersão dos dados e por fim o coeficiente de variação. Esta é uma medida de dispersão

relativa, pois expressa a relação percentual do desvio padrão pela média. Com base na média

será feito a tabela de dados e um gráfico de barras proporcional comparando versões de

algoritmos. (GUIMARÃES, 2002; ZAMBERLAN, 2011). As fórmulas utilizadas nos testes

com 50 execuções de média aritmética ( ), desvio padrão ( ) e coeficiente de variação (CV)

utilizadas estão na figura 3, 4 e 5 respectivamente:

Figura 3 – Média aritmética.

√∑

Figura 4 – Desvio padrão.

Figura 5 – Coeficiente de variação.

Assim, a próxima seção apresenta os resultados e suas análises.

24

4 RESULTADOS E DISCUSSÕES

4.1 IMPLEMENTAÇÃO INICIAL

Existem diversas técnicas para otimização do código pelo programador, como algumas

novas técnicas da recente especificação da linguagem, o que muitas vezes confunde o

programador vindo de outra linguagem. Os novos programadores e até os mais experientes

podem não ter o conhecimento a respeito das técnicas. Então a mensuração e os resultados das

otimizações citadas na seção 2 se tornam importantes, tanto para ser atualizada com os novos

conceitos quanto para ser comprovada pelas estatísticas geradas pelo trabalho.

Antes de aplicar as técnicas deve-se remodelar o algoritmo para os três tipos de dados

usados nos experimentos, o algoritmo inicial desenvolvido ordena um vetor de caracteres,

para isso precisa-se adaptar para cada um dos tipos propostos: int (código-fonte 5),

std::string (código-fonte 6) e TCC::Pessoa (código-fonte 7) chamada de versão 1,

representado pela sigla V1.

1 void quick_int(int *item, int count)

2 {

3 qs_int(item, 0, count - 1);

4 }

5

6 void qs_int(int *item, int left, int right)

7 {

8 int i, j, x, y;

9

10 i = left; j = right;

11 x = item[(left + right)/2];

12

13 do {

14 while(item[i]<x && i<right) i++;

15 while(x<item[j] && j>left) j--;

16

17 if(i<=j) {

18 y = item[i];

19 item[i] = item[j];

20 item[j] = y;

21 i++; j--;

22 }

23 } while(i<=j);

24

25

25 if(left<j) qs_int(item, left, j);

26 if(i<right) qs_int(item, i, right);

27 }

Código-fonte 5 – Quicksort para ordenar inteiros.

1 void quick_string(std::string *item, int count)

2 {

3 qs_string(item, 0, count - 1);

4 }

5

6 void qs_string(std::string *item, int left, int right)

7 {

8 int i, j;

9 std::string x, y;

10

11 i = left; j = right;

12 x = item[(left + right)/2];

13

14 do {

15 while(item[i]<x && i<right) i++;

16 while(x<item[j] && j>left) j--;

17

18 if(i<=j) {

19 y = item[i];

20 item[i] = item[j];

21 item[j] = y;

22 i++; j--;

23 }

24 } while(i<=j);

25

26 if(left<j) qs_string(item, left, j);

27 if(i<right) qs_string(item, i, right);

28 }

Código-fonte 6 – Quicksort para ordenar strings.

1 void quick_pessoa(TCC::Pessoa *item, int count)

2 {

3 qs_pessoa(item, 0, count - 1);

4 }

5

6 void qs_pessoa(TCC::Pessoa *item, int left, int right)

7 {

26

8 int i, j;

9 TCC::Pessoa x, y;

10

11 i = left; j = right;

12 x = item[(left + right)/2];

13

14 do {

15 while((item[i].valorNome() < x.valorNome()) && (i < right)) i++;

16 while((x.valorNome() < item[j].valorNome()) && (j > left)) j--;

17

18 if(i<=j) {

19 y = item[i];

20 item[i] = item[j];

21 item[j] = y;

22 i++; j--;

23 }

24 } while(i<=j);

25

26 if(left<j) qs_pessoa(item, left, j);

27 if(i<right) qs_pessoa(item, i, right);

28 }

Código-fonte 7 – Quicksort para ordenar pessoas.

Teve-se que criar a versão inicial do Quicksort para os três tipos de dados testados,

devido ao algoritmo inicial ordenar um conjuto de caracteres (char). As alterações são

mínimas para redimensionar de um tipo para outro, a versão para int e std::string foram

alteradas apenas os parâmetros, o pivô e a variável auxiliar para realizar as trocas no conjunto,

visto que os dois tipos possuem suporte ao operador menor que (<). O tipo próprio, além

dessas modificações, teve que ser escolhido o atributo nome da classe TCC::Pessoa para

ordenação, no caso o nome. O tempo de ordenação em milissegundos obtido para cada um

dos tipos está na tabela 1:

Característica quick_int quick_string quick_pessoa

V1

Tempo (ms) 454,48 15.606 31.216

Desvio Padrão (ms) 6,0737 218,33 398,82

Variação CV (%) 1,3364 1,399 1,2776

Tabela 1 – Dados da execução da versão inicial do Quicksort.

27

Pode-se observar que o tempo da ordenação de inteiro foi o mais rápido dos tipos

testados, visto a simplicidade que tem o tipo de dado tanto na comparação e cópia que o

algoritmo realiza. Isto não ocorre aos objetos mais complexos. Nos dois outros casos

(std::string e TCC::Pessoa) foi usada a comparação lexicográfica do operador menor que

(<) da classe std::string, e a operação de cópia da quick_string foi mais rápida, o que já

era esperado porque a classe TCC::Pessoa possui dos quatro, dois atributos std::string que

foram copiados. Percebe-se também na análise da tabela 1 que o coeficiente de variação CV

ficou baixo, isto é menor que 2, que é considerado uma amostra homogênea segundo

Zamberlan (2011). Até mesmo o quick_pessoa com desvio padrão de 398,82 milissegundos

ficou com coeficiente pequeno, devida a proporção ao tempo corrido de 31,216 segundos.

4.2 PRIMEIRA TÉCNICA: GENERALIZAÇÃO COM TEMPLATES

A primeira otimização escolhida para o algoritmo será de generalizar usando

templates, não necessariamente gerando uma otimização de tempo de execução, mas sim uma

ferramenta para o programador. Como o tipo está fixo no algoritmo, perde-se muito tempo

gerando uma versão para cada tipo de dados que a aplicação venha a utilizar. E também com a

repetição de código ao realizar uma otimização em um, a mesma deve ser replicada para todos

os outros, levando a perda de produtividade do desenvolvedor.

Para generalizar o código deve ser identificado os pontos dependentes de um tipo de

dado ou operação, deixando apenas o conceito do algoritmo independente. Como já foram

feitas três versões ficou evidente que os pontos são: o vetor de ponteiros passado como

argumento *item, o pivô x e a variável auxiliar para trocas y. Um ponto mais sutil são as

comparações com objetos com mais atributos, conforme notado pela diferença na função

quick_pessoa (código-fonte 7). Podemos generalizar elas também, fazendo que o usuário do

código forneça uma função de comparação, oque é útil visto a classe TCC::Pessoa, por

exemplo, que não implementa o operador menor que (<). Entretanto como vários tipos

implementam o comparador menor que (como o int e a std::string), podemos deixá-lo

como padrão (código-fonte 8).

1 #include <functional>

2

3 template <typename tipo_dado,

4 typename tipo_comp = std::less<tipo_dado> >

5 void quick(tipo_dado *item, int count,

6 tipo_comp comparador = std::less<tipo_dado>())

28

7 {

8 qs(item, 0, count - 1, comparador);

9 }

10

11 template <typename tipo_dado,

12 typename tipo_comp>

13 void qs(tipo_dado *item, int left, int right,

14 tipo_comp comparador)

15 {

16 int i, j;

17 tipo_dado x, y;

18

19 i = left; j = right;

20 x = item[(left + right)/2];

21

22 do {

23 while(comparador(item[i], x) && (i < right)) i++;

24 while(comparador(x, item[j]) && (j > left)) j--;

25

26 if(i<=j) {

27 y = item[i];

28 item[i] = item[j];

29 item[j] = y;

30 i++; j--;

31 }

32 } while(i<=j);

33

34 if(left<j) qs(item, left, j, comparador);

35 if(i<right) qs(item, i, right, comparador);

36 }

Código-fonte 8 – Versão 2 genérica do Quicksort.

Como pode-se ver a versão genérica separa o conceito, apresentada em código-fonte 8,

dos tipo de dados utilizados e através dela consegue-se até mesmo generalizar operações

utilizadas no algoritmo. Nas linhas 3 e 4 temos a palavra chave template com os argumentos

utilizados pela função, que são: o primeiro tipo_dado representa o tipo de dado do conjunto

que será ordenado e o outro tipo_comp representa o tipo da função de comparação que é

definida a função std::less<tipo_dado> como padrão, caso na chamada da função não for

especificado nenhum outro. A classe std::less definida na biblioteca functional incluída

na linha 1, é uma classe genérica que implementa uma função que recebe dois parâmetros e

29

retorna se o primeiro é menor que o segundo (<), que no caso é repassado o parâmetro

tipo_dado. Quando necessário pode ser informado o parâmetro comparador com alguma

função própria, a restrição é que a função deve receber dois parâmetros do tipo_dado e deve

retornar um booleano. Foi adicionado o parâmetro que recebe a função nas duas funções de

ordenação, o código cliente da função pode ser das seguintes formas exemplificadas no

código fonte 9:

1 // ... conjunto de inteiros(int)

2 quick(dados_int, TAMANHO);

3

4 // ... conjunto de pontos flutuantes(float)

5 quick(dados_float, TAMANHO);

6

7 // ... conjunto de inteiros(int)

8 quick(dados_int, TAMANHO, std::greater<int>());

9

10 // ... conjunto de pessoas(TCC::Pessoa)

11 quick(dados_pessoa, TAMANHO,

12 [](TCC::Pessoa a, TCC::Pessoa b)

13 {

14 return a.valorNome() < b.valorNome()

15 });

Código-fonte 9 – Exemplos de uso da ordenação genérica.

Nas linhas 2, 5, 8 e 11 do código-fonte 9, a função pode ser utilizada agora com toda

variedade de tipos e métodos de comparação. Não é necessário deixar explícitos os

argumentos do template, pois é de responsabilidade do compilador deduzi-los baseado nos

parâmetros. Na linha 8 é utilizado a classe std::greater que é complementar a std::less

da biblioteca functional, nele deixa-se explícito o parâmetro pois não há argumentos na

construção. Utilizando ele ordena-se o conjunto de forma decrescente, tendo mais uma

conveniência em generalizar o algoritmo. Na chamada da linha 11 como o tipo TCC::Pessoa

não possui o comparador menor que (<), utiliza-se uma função anônima escolhendo o atributo

de nome para a ordenação crescente.

De início nota-se o ganho em tempo de desenvolvimento dessa técnica, pois

conseguimos usar o algoritmo agora com qualquer tipo de dados. Deve-se traçar um paralelo,

verificando se o desempenho do algoritmo permanece semelhante após generalizar o código,

para analisar a viabilidade do ponto de vista do desempenho. Os valores podem ser

visualizados na tabela 2.

30

Característica quick<int> quick<string> quick<pessoa>

V2

Tempo (ms) 456,4 15.693 442.062

Desvio Padrão (ms) 7,7253 232,11 7496,7

Variação CV (%) 1,6927 1,4791 1,6958

Tabela 2 – Dados da execução da versão genérica do Quicksort.

Pode-se ver que os tempos de execução das funções (V2) parametrizadas com o int e

o std::string permaneceram semelhantes a versão anterior (V1), visto pela margem de

desvio padrão. O que não se esperava era o desempenho da função com o tipo TCC::Pessoa

decair tanto contra a implementação anterior, conforme mostra-se no gráfico 1:

Gráfico 1 – Relação entre as execuções das versões do algoritmo Quicksort.

Verifica-se que a execução da ordenação de pessoas aumentou em 1.416,2 % em

relação à versão não genérica anterior, em uma análise mais aprofundada dos códigos-fontes

percebe-se que a adição do parâmetro não diminuiu a performance. Pois com os outros tipos

de dados a eficiência se manteve, então o problema pode estar no comparador definido na

chamada para o algoritmo da pessoa. Como o algoritmo recebe os dois objetos pessoa por

cópia e a comparação está em dois laços dentro do código, há a cópia várias vezes dos objetos

desnecessariamente. Assim podemos tomar como exemplo, nesse caso, como os objetos

temporários denigre o desempenho do algoritmo em uma mudança simples, sendo

responsabilidade de o programador identificar e aperfeiçoar a implementação do algoritmo.

31

Com isso, surge a oportunidade de demonstrar a segunda técnica para otimizar a

performance da ordenação, que é a passagem por referência dos parâmetros usados.

4.3 SEGUNDA TÉCNICA: PASSAGEM POR REFERÊNCIA

Assim, os objetos ao serem comparados na ordenação da pessoa, podem ser passados

por referências ao invés da cópia e como o ponteiro é do tamanho da palavra do processador,

isto se torna trivial. A nova versão 3 do algoritmo pode ser vista no código-fonte 10:

1 #include <functional>

2

3 template <typename tipo_dado,

4 typename tipo_comp = std::less<tipo_dado> >

5 void quick(tipo_dado *item, int count,

6 const tipo_comp& comparador = std::less<tipo_dado>())

7 {

8 qs(item, 0, count - 1, comparador);

9 }

10

11 template <typename tipo_dado,

12 typename tipo_comp>

13 void qs(tipo_dado *item, int left, int right,

14 const tipo_comp& comparador = std::less<tipo_dado>())

15 {

16 int i, j;

17 tipo_dado x, y;

18

19 i = left; j = right;

20 x = item[(left + right)/2];

21

22 do {

23 while(comparador(item[i], x) && (i < right)) i++;

24 while(comparador(x, item[j]) && (j > left)) j--;

25

26 if(i<=j) {

27 y = item[i];

28 item[i] = item[j];

29 item[j] = y;

30 i++; j--;

31 }

32 } while(i<=j);

32

33

34 if(left<j) qs(item, left, j, comparador);

35 if(i<right) qs(item, i, right, comparador);

36 }

Código-fonte 10 – Versão 3 com o uso de referências.

A versão 3 apenas altera as linha 6 e 14 conforme o código-fonte 10, que são as únicas

variáveis copiadas que podem ter impacto na eficiência dependendo do comparador utilizado,

os outros inteiros copiados são tipos simples e não há ganho em passar por referência. O

ganho na referência é melhor para objetos que possuem construtores que são onerosos, que é

o caso do código cliente da ordenação usando o tipo TCC::Pessoa, na função que implementa

o comparador utilizado no algoritmo para não gerar cópias temporárias (código-fonte 11).

1 // ... conjunto de pessoas(TCC::Pessoa)

2 quick(dados_pessoa, TAMANHO,

3 [](const TCC::Pessoa& a, const TCC::Pessoa& b)

4 {

5 return a.valorNome() < b.valorNome()

6 });

Código-fonte 11 – Alteração no código cliente da função para passagem de referências.

A alteração evitará o construtor de cópia, apenas definindo na linha 3 do código-fonte

11, os parâmetros para serem referências TCC::Pessoa& e garante-se que não há alteração nas

variáveis através da diretiva const. Na nova execução observa-se a alteração no tempo de

execução da tabela 3:

Característica quick<int> quick<string> quick<pessoa>

V3

Tempo (ms) 435,74 15.787 60.374

Desvio Padrão (ms) 4,8654 192,6 827,75

Variação CV (%) 1,1166 1,22 1,3771

Tabela 3 – Dados da execução da versão utilizando passagem de referência.

Conforme analisado e comprovado, parte da perda na performance da segunda versão

para o tipo TCC::Pessoa foi causado pelas cópias do comparador, visto que o tempo na

segunda versão ficou em 442.062 ms e nessa última em 60.374 ms (gráfico 2). É importante

conhecer a utilidade de se passar por referência ao invés de valor, por causa de ser uma

técnica que pode ajudar em muito o desempenho em laços que usam funções.

33

Gráfico 2 – Relação entre as execuções do Quicksort.

Através gráfico e dados apresentados, verifica-se que o uso de templates não interferiu

na eficiência do código nos tipos mais simples int e std::string, pois melhorou muito o

reuso para utilização com qualquer tipo de dado e diminui o trabalho de manutenção, sendo

uma técnica eficaz. O uso da função da comparação no caso do tipo TCC:Pessoa evidenciou a

perda de performance em utilizar a passagem de parâmetros por cópia, sendo um problema

que o programador deve sempre estar atento. Porém se faz necessária uma melhor análise

visto que ainda o desempenho ficou pior em relação à primeira versão no tipo TCC::Pessoa.

Mas como se demonstrou a passagem por referência otimiza e elimina parte dessa perda de

processamento com uma solução simples e clara, que deve ser parte indispensável do

conhecimento técnico do profissional em C++.

A próxima estratégia para conseguir um melhor desempenho será a terceira técnica de

declaração de variáveis no escopo de utilização.

34

4.4 TERCEIRA TÉCNICA: DECLARAÇÃO DE VARIÁVEIS EM ESCOPO DE

UTILIZAÇÃO

Essa técnica se baseia no fato de que em C++ não é necessário declarar as variáveis no

inicio do escopo do código. Assim podemos declarar as variáveis apenas no escopo onde é

utilizada, isso se torna uma vantagem a partir do momento que pode ser declarada a variável

dentro do escopo de uma estrutura de controle de fluxo, como um if, fazendo o custo da

variável ser condicional em tempo de execução.

Verificando o algoritmo em sua versão 3, observamos que a única declaração fora do

escopo de utilização é a variável y na linha 17 que é utilizada como auxiliar nas trocas dos

elementos no bloco condicional das linhas 26 a 31. A quarta versão com a declaração da

variável y no escopo de utilização pode ser vista no código-fonte 12:

1 #include <functional>

2

3 template <typename tipo_dado,

4 typename tipo_comp = std::less<tipo_dado>>

5 void quick(tipo_dado *item, int count,

6 const tipo_comp& comparador)

7 {

8 qs(item, 0, count - 1, comparador);

9 }

10

11 template <typename tipo_dado,

12 typename tipo_comp>

13 void qs(tipo_dado *item, int left, int right,

14 const tipo_comp& comparador)

15 {

16 int i, j;

17 tipo_dado x;

18

19 i = left; j = right;

20 x = item[(left + right)/2];

21

22 do {

23 while(comparador(item[i], x) && (i < right)) i++;

24 while(comparador(x, item[j]) && (j > left)) j--;

25

26 if(i<=j) {

27 static tipo_dado y;

35

28 y = item[i];

29 item[i] = item[j];

30 item[j] = y;

31 i++; j--;

32 }

33 } while(i<=j);

34

35 if(left<j) qs(item, left, j, comparador);

36 if(i<right) qs(item, i, right, comparador);

37 }

Código-fonte 12 – Versão 4 do Quicksort usando a declaração de variáveis no escopo de utilização.

Para aplicar a técnica na variável y, foram feitas duas alterações: Foi removida a

declaração na linha 17 e foi transferido o ponto de declaração para o bloco onde é usada na

linha 27. Assim a criação da variável fica condicionada as trocas realizadas pelo algoritmo, se

por exemplo, for informado um conjunto já ordenado a variável não irá ser nem criada, pois

não haverá trocas. O resultado no desempenho da alteração pode ser visto na tabela 4.

Característica quick_int quick_string quick_pessoa

V4

Tempo (ms) 432,2 14.362 56.975

Desvio Padrão (ms) 4,0694 199,32 797,41

Variação CV (%) 0,9639 1,3878 1,3996

Tabela 4 – Dados da execução na versão utilizando a declaração no escopo de utilização.

Realizada a modificação verifica-se um ganho principalmente nos tipos mais

complexos visto que a criação da variável sem uso causa uma sobrecarga maior em tempo de

execução para estes tipos, pois possuem construtores mais complexos em relação ao primeiro

tipo nativo int. Assim pode-se aplicar o complemento a técnica de declaração no escopo, que

é a quarta técnica, inicialização de variáveis na declaração.

4.5 QUARTA TÉCNICA: INICIALIZAÇÃO DE VARIÁVEIS

Iniciar a variável na declaração é uma boa prática, pois ao declarar uma variável é

chamado seu construtor padrão, que inicializa corretamente os atributos da variável, ao

declarar e após inicializar a variável com o valor desejado são chamados o construtor padrão e

o operador de atribuição enquanto que declarar inicializando a variável chama apenas seu

construtor de cópia. Aplica-se nas variáveis do algoritmo:

36

1 #include <functional>

2

3 template <typename tipo_dado,

4 typename tipo_comp = std::less<tipo_dado>>

5 void quick(tipo_dado *item, int count,

6 const tipo_comp& comparador)

7 {

8 qs(item, 0, count - 1, comparador);

9 }

10

11 template <typename tipo_dado,

12 typename tipo_comp>

13 void qs(tipo_dado *item, int left, int right,

14 const tipo_comp& comparador)

15 {

16 int i = left, j = right;

17

18 static auto x = item[(left + right)/2];

19 do {

20 while(comparador(item[i], x) && (i < right)) i++;

21 while(comparador(x, item[j]) && (j > left)) j--;

22

23 if(i<=j) {

24 static auto y = item[i];

25 item[i] = item[j];

26 item[j] = y;

27 i++; j--;

28 }

29 } while(i<=j);

30

31 if(left<j) qs(item, left, j, comparador);

32 if(i<right) qs(item, i, right, comparador);

33 }

Código-fonte 13 – Versão 5 com a quarta técnica de inicialização aplicada.

Aplicando a técnica nas variáveis locais inteiras i e j (linha 16) apenas para

demonstração da técnica pois para tipos nativos não há construtor. O verdadeiro uso da

técnica é na variável x (linha 18) e y (linha 24) que possuem construtores mais complexos nos

casos em que a função é chamada com os tipos std::string e TCC::Pessoa. Como estamos

declarando e inicializando a variável podemos usar a nova diretiva auto para delegar ao

37

compilador a definição do tipo da variável. Usando o qualificador static, garantimos

também que o construtor de destruição não será chamado desnecessariamente. A execução da

nova versão do algoritmo produziu os valores constantes na tabela 5.

Característica quick<int> quick<string> quick<pessoa>

V5

Tempo (ms) 420,9 14.290 56.829

Desvio Padrão (ms) 4,5 222,3 770,51

Variação CV (%) 1,00651 1,5426 1,3559

Tabela 5 – Versão 5 com os dados de tempo de execução.

A técnica surtiu uma pequena melhora em relação a milésimos de segundos nos

desempenhos dos algoritmos, mesmo assim é uma técnica bem simples e que o programador

pode aplicar durante a implementação do algoritmo. No gráfico 3 temos as melhoras das

versões 4 e 5 em relação a inicial:

Gráfico 3 - Relação das melhorias entre técnicas.

Nota-se que a técnica de declaração no escopo de uso causou uma melhora maior no

desempenho do que a inicialização, porém ambas melhoraram o desempenho de maneira

geral. O desempenho da versão usando o TCC::Pessoa com o comparador por função

anônima ainda não recuperou o desempenho da primeira versão, possivelmente pelo fato da

versão genérica chamar a função anônima e então chamar a função para recuperar o atributo

do nome então essa chamada a mais deve estar diminuindo o desempenho.

38

Para diminuir esse encargo, a chamada a duas funções, poderá ser usada à diretiva

inline. Ela elimina a chamada trazendo o corpo da função no ponto de chamada. Mas antes,

para aproveitar todo o beneficio de qualificar as funções e as chamadas dos atributos, deve-se

retirar a recursão do algoritmo de ordenação. Para assim demonstrar a técnica de inline nas

funções.

Então a quinta técnica demonstrada será o uso da recursão, que é uma técnica que

depende do bom senso do programador e a correta análise.

4.6 QUINTA TÉCNICA: O USO DA RECURSÃO

É uma técnica que muda o design do algoritmo e pode ser que nem sempre aumente o

desempenho do algoritmo. Para aumentar a eficiência é esperado que ao transformar o

algoritmo para sua forma iterativa e eliminar a sobrecarga de chamadas repetidas as funções

recursivas e posteriormente viabilizar a técnica do inline, visto que o compilador realiza a

otimização em uma função iterativa. Nosso algoritmo de ordenação demonstrado pode ser

visto em sua forma iterativa no código fonte 16:

1 #include <functional>

2 #include <stack>

3 #include <utility>

5 template <typename tipo_dado,

6 typename comp = std::less<tipo_dado>>

7 void quick(tipo_dado *item, int count,

8 const comp& comparador)

9 {

10 int i, j, left, right;

11 std::stack<std::pair<int, int>> intervalo;

12

13 intervalo.push({0, count - 1});

14

15 while (intervalo.size() > 0) {

16 left = intervalo.top().first;

17 right = intervalo.top().second;

18 intervalo.pop();

19

20 i = left; j = right;

21 static auto x = item[(left + right) / 2];

22

23 do {

39

24 while(comparador(item[i], x) && (i < right)) i++;

25 while(comparador(x, item[j]) && (j > left)) j--;

26

27 if(i<=j) {

28 static auto y = item[i];

29 item[i] = item[j];

30 item[j] = y;

31 i++; j--;

32 }

33 } while(i<=j);

34

35 if(left<j) intervalo.push({left, j});

36 if(i<right) intervalo.push({i, right});

37 }

38 }

Código-fonte 14 – Versão 6 iterativa do Quicksort.

Para redimensionar o algoritmo em sua versão iterativa, houve diversas alterações para

gerar um algoritmo que altere a lógica recursiva, a primeira foi eliminar a função auxiliar qs

que era chamada a cada recursão, e seu corpo foi incorporado na função quicksort. Para

simular a execução da recursão em que cada intervalo era particionado e passado a função

recursiva, foi usada uma estrutura de pilha intervalo. Este foi declarado na linha 12,

utilizando o tipo de dado std::pair<int, int>, incluso na linha 3 através da biblioteca

utility, esse tipo representa um par ordenado de dois tipos que são acessados pelas variáveis

internas do tipo: .first e .second. Cada elemento da pilha denota um subconjunto denotado

pelo limite inferior left e o limite superior right.

Assim os pares inseridos na pilha intervalo representam os subconjuntos que irão ser

processados, inserindo primeiramente o intervalo completo na linha 15. O método para

acesso que foi utilizado como pilha para funcionar como a recursão, controlando o número de

execuções como pode ser visto na linha 17, que enquanto houver intervalos a serem

processados (size() > 0) o algoritmo recupera o valor do limite inferior e o superior do

topo do vetor e já o remove utilizando a função pop() pois será processado. Para assim gerar

os subconjuntos conforme o algoritmo recursivo realizava, para que ao final nas linhas 37 e

38 realimentar a pilha com os novos subconjuntos.

Assim consegue-se o mesmo algoritmo utilizando o laço iterativo substituindo a

recursão, para verificar o impacto no desempenho das chamadas recursivas. Como houve a

inserção da variável do tipo pilha, fornecido pela biblioteca padrão do C++, pode ser que o

40

desempenho seja prejudicado. Então é uma técnica que depende do impacto na alteração do

algoritmo, a mensuração de tempo de execução é indispensável para o programador tomar a

decisão correta na escolha da recursão ou iteração. No novo lote de testes com as

modificações da versão 5 pode ser vista na tabela:

Característica quick<int> quick<string> quick<pessoa>

V6

Tempo (ms) 412,16 12.251 52.762

Desvio Padrão (ms) 3,1353 167,07 866,64

Variação CV (%) 0,6946 1,3637 1,6426

Tabela 6 – Dados da execução da versão iterativa.

Tem-se um grande aumento na performance da versão iterativa sobre a recursiva nos

dois tipos de dados mais complexos, chegando até em uma melhora de mais de 21% sobre a

versão inicial do tipo de dados std::string, mesmo usando o objeto std::stack para

auxiliar o controle do algoritmo. Ficando evidenciado pelo gráfico 4.

Gráfico 4 – Proporção da melhoria entre as versões do algoritmo.

Sendo assim podemos prosseguir com a sexta técnica que pode eliminar a sobrecarga

da chamada para função que é o qualificador inline.

41

4.7 SEXTA TÉCNICA: FUNÇÕES QUALIFICADAS COM A DIRETIVA INLINE

Qualificando uma função com inline indica-se ao compilador para, se possível,

substituir a chamada à função pelo seu corpo no ponto de chamada, reduzindo os passos que o

fluxo de instruções deve seguir. Pois ao chamar uma função o processador deve guardar os

estados dos registradores, inserir os argumentos na pilha e reservar um endereço para o

retorno para assim passar a execução pelas instruções da função. Então ao realizar o inline o

compilador trás todo o corpo da função e as instruções ficam todas sequenciais

(GOLDTHWAITHE, STROUSTRUP; 2006; p. 27).

Analisando os códigos-fonte das classes utilizadas, podemos ver que a classe

TCC::Pessoa possui as chamadas valorNome que é utilizada no algoritmo de ordenação na

função de comparação é que uma boa candidata para receber o qualificador inline. A nova

versão do algoritmo (V7) com inline (código-fonte 14) e as modificações na classe

TCC::Pessoa (código-fonte 15) podem ser vistas a seguir:

1 #include <functional>

2 #include <stack>

3 #include <utility>

5 template <typename tipo_dado,

6 typename comp = std::less<tipo_dado>>

7 inline void quick(tipo_dado *item, int count,

8 const comp& comparador)

9 {

10 int i, j, left, right;

11 std::stack<std::pair<int, int>> intervalo;

12

13 intervalo.push({0, count - 1});

14

15 while (intervalo.size() > 0) {

16 left = intervalo.top().first;

17 right = intervalo.top().second;

18 intervalo.pop();

19

20 i = left; j = right;

21 static auto& x = item[(left + right) / 2];

22

23 do {

24 while(comparador(item[i], x) && (i < right)) i++;

25 while(comparador(x, item[j]) && (j > left)) j--;

42

26

27 if(i<=j) {

28 static auto y = item[i];

29 item[i] = item[j];

30 item[j] = y;

31 i++; j--;

32 }

33 } while(i<=j);

34

35 if(left<j) intervalo.push({left, j});

36 if(i<right) intervalo.push({i, right});

37 }

38 }

Código-fonte 15 – Versão 7 do algoritmo Quicksort.

1 class Pessoa

2 {

3 public: enum class tipo_sexo { masculino, feminino };

4 private:

5 std::string nome;

6 Pessoa::tipo_sexo sexo;

7 float peso;

8 std::string endereco;

9 public:

10 inline explicit Pessoa(const std::string nome,

11 const Pessoa::tipo_sexo sexo, const float peso,

12 const std::string endereco)

13 : nome(nome)

14 , sexo(sexo)

15 , peso(peso)

16 , endereco(endereco) { };

17

18 inline std::string valorNome() const { return this->nome; };

19 inline Pessoa::tipo_sexo valorSexo() const { return

this->sexo; };

20 inline float valorPeso() const { return this->peso; };

21 inline std::string valorEndereco() const { return

this->endereco; };

22 };

Código-fonte 16 – Alterações nas funções membro do tipo para representação de pessoas.

43

As alterações nos algoritmos foram apenas na assinatura das funções adicionando o

qualificador inline em sua definição. A partir dessa modificação no algoritmo de ordenação

poderemos verificar se o compilador irá realizar o inline da função ou se vai desconsiderar o

qualificador inserido. De esperado é que a ordenação de pessoas fique com uma melhor

eficiência, pois a chamada ao atributo nome é usada em todas as comparações feitas com os

dados. Os dados da execução da versão 7 foram os seguintes (Tabela 4):

Característica quick<int> quick<string> quick<pessoa>

V7

Tempo (ms) 410 12.213 23.008

Desvio Padrão (ms) 7,5793 201,17 303,19

Variação CV (%) 1,717 1,6473 1,3178

Tabela 7 – Dados da execução da versão utilizando a expansão da função.

Conforme os dados percebe-se uma melhora mensurável na execução com o tipo

TCC::Pessoa em que a execução passou de 57 segundos na versão 6 para 23 segundos na

versão 7, isso devido as chamadas ao comparador que usou a função realizando o inline

(gráfico 5).

Gráfico 5 – Comparações entre versões.

No gráfico nota-se que a partir dessa técnica houve uma melhora considerável na

ordenação do tipo TCC::Pessoa, visto o problema que causou a perca de desempenho ao

transformar o algoritmo em genérico na versão 2. Vê-se que a ordenação do tipo mais

44

primitivo (int) não se beneficia muito em relação aos outros tipos, parte disso é causa do int

ser um tipo primitivo que já é otimizado visto a simplicidade da comparação e cópia dele,

assim temporários desse tipo quase não afetam a performance em tempo de execução.

Visto a melhora nos tipos complexos pode-se partir para a sétima técnica que

beneficiará ainda mais tipos com construtores complexos, que é a semântica de movimento.

4.8 SÉTIMA TÉCNICA: SEMÂNTICA DE MOVIMENTO

Recentemente adicionado esse conceito no C++, essa técnica vem acabar com a

criação de temporários que prejudicava em muito o desempenho (STROUSTUP, 2013, p.

317). Analisando o código da ordenação pode-se ver que um temporário é gerado sempre a

cada troca de elemento no conjunto na linha 28 a 30, onde a variável y é auxiliar na troca do

valor dos elementos. Assim tem-se um ponto onde pode ser aplicada a técnica para apenas

“trocar” o valor dos dois elementos através da semântica de movimento aplicada no código-

fonte 17.

1 #include <functional>

2 #include <stack>

3 #include <utility>

4 template <typename tipo_dado,

5 typename comp = std::less<tipo_dado>>

6 inline void quick(tipo_dado *item, int count,

7 const comp& comparador)

8 {

9 int i, j, left, right;

10 std::stack<std::pair<int, int>> intervalo;

11

12 intervalo.push({0, count - 1});

13

14 while (intervalo.size() > 0) {

15 left = intervalo.top().first;

16 right = intervalo.top().second;

17 intervalo.pop();

18

19 i = left; j = right;

20 static auto x = item[(left + right) / 2];

21

22 do {

23 while(comparador(item[i], x) && (i < right)) i++;

45

24 while(comparador(x, item[j]) && (j > left)) j--;

25

26 if(i<=j) {

27 std::swap(item[i], item[j]);

28 i++; j--;

29 }

30 } while(i<=j);

31

32 if(left<j) intervalo.push({left, j});

33 if(i<right) intervalo.push({i, right});

34 }

35 }

Código-fonte 17 – Versão 8 do algoritmo Quicksort usando a semântica de movimento.

A alteração no código-fonte foi na linha 27 onde usamos a função de troca provida

pela biblioteca padrão do C++, internamente a função verifica se os argumentos podem ser

movidos ou apenas copiados. Se o tipo prover o construtor de movimento a função realiza

apenas o movimento entre as duas variáveis informadas, aumentando assim o desempenho da

troca. Como o tipo std::string já provêm a semântica de movimento, é necessário apenas

modificar a definição da classe TCC::Pessoa para habilitar o movimento de seus atributos

internos, conforme código-fonte 18.

1 class Pessoa

2 {

3 public: enum class tipo_sexo { masculino, feminino };

4 private:

5 std::string nome;

6 Pessoa::tipo_sexo sexo;

7 float peso;

8 std::string endereco;

9 public:

10 inline explicit Pessoa(const std::string nome,

11 const Pessoa::tipo_sexo sexo, const float peso,

12 const std::string endereco)

13 : nome(nome)

14 , sexo(sexo)

15 , peso(peso)

16 , endereco(endereco) { };

17 inline Pessoa(Pessoa&) = default;

18 inline Pessoa(Pessoa&&) = default;

46

19 inline Pessoa& operator=(Pessoa&) = default;

20 inline Pessoa& operator=(Pessoa&&) = default;

21

22 inline std::string valorNome() const { return this->nome; };

23 inline Pessoa::tipo_sexo valorSexo() const { return

this->sexo; };

24 inline float valorPeso() const { return this->peso; };

25 inline std::string valorEndereco() const { return

this->endereco; };

26 };

Código-fonte 18 – Alterações para permitir a semântica de movimento.

As modificações na classe TCC::Pessoa para habilitar a semântica de movimento são

as definições da linha 17 a 20, como a classe não usa nenhum recurso de responsabilidade

dela ao definir os construtores de movimento como default ele repassa ao compilador a

criação do movimento dos seus atributos, no caso os atributos nome e endereco se

beneficiarão da alteração pois são responsáveis pela memória onde é armazenado o texto. Os

tempos de execução estão na tabela 8.

Característica quick_int quick_string quick_pessoa

V8

Tempo (ms) 411,04 10.534 21.242

Desvio Padrão (ms) 7,80 189,89 324,48

Variação CV (%) 1,8976 1,8026 1,5275

Tabela 8 – Dados da execução da versão com movimento.

Pela tabela pode-se notar que o tempo com o tipo std::string melhorou em relação

a versão anterior em pouco mais de 2 segundos, visto que o tipo possui memória alocada

dinamicamente para conter o texto assim utilizando o movimento ao invés da cópia a

memória não é copiada apenas o ponteiro dela é movido de posição a outra no conjunto. A

ordenação do tipo TCC::Pessoa também beneficiou dessa técnica visto que possui dois

atributos do tipo std::string. Já o tipo inteiro não houve mudanças mostrando assim a

flexibilidade da função std::swap usando a semântica de movimento quando necessário.

A semântica de movimento é uma técnica poderosa que ajuda em muito a passagem de

recursos entre escopos. Porém conforme visto na alteração da ordenação a responsabilidade

de prover a semântica de movimento das classes é do programador, adicionando o construtor

de movimento e o operador de atribuição de movimento. Sendo muito importante quem

implementar a classe provenha as funcionalidades para ganho em desempenho no uso. Na

comparação com a versão anterior e inicial tem-se o gráfico:

47

Gráfico 6 – Relação de execuções das técnicas.

A oitava técnica demonstrada será o uso de threads, pode-se usar a concorrência para

aumentar o desempenho do algoritmo de ordenação dividindo o processamento para outras

threads.

4.9 OITAVA TÉCNICA: EXECUTAR TAREFAS CONCORRENTEMENTE COM O

USO DE THREADS

Analisando a última versão do algoritmo necessita-se encontrar uma maneira de

repensar o algoritmo fazendo-o possível dividir o trabalho em unidades para serem

processados. Verifica-se, na versão 8 (código-fonte 17), que pode separar os intervalos que

estão sendo ordenados, semelhante ao que ocorria na versão recursiva, pela estrutura de pilha

intervalo (linha 10). Os subconjuntos são identificados após o particionamento utilizando o

pivô, e são inseridos na pilha nas linhas 32 e 33. Contendo os índices dos intervalos inferior e

superior, pode-se ser usado para chamar novamente a função em outra thread.

Como o problema é reduzido do conjunto inicial para os dois subconjuntos e inserido

na estrutura de pilha podemos remover o elemento da pilha e criar uma nova thread com o

intervalo removido. Assim a nova thread possui um intervalo novo para se processar que não

interfere no trabalho da principal. Utilizando esse algoritmo na nova versão alterada para

permitir concorrência no código fonte 19.

48

1 #include <stack>

2 #include <utility>

3 #include <atomic>

4 #include <thread>

5 std::vector<std::thread> threads;

6 template <typename tipo_dado,

7 typename comp = std::less<tipo_dado>>

8 inline void quick(tipo_dado *item, int count,

9 const comp& comparador)

10 {

11 int i, j, left, right;

12 std::stack<std::pair<int, int>> intervalo;

13

14 static std::atomic<int> num_threads { 0 };

15 bool princ { (num_threads == 0) ? true : false };

16

17 if (princ) num_threads++;

18

19 intervalo.push({0, count - 1});

20 while (intervalo.size() > 0) {

21 left = intervalo.top().first;

22 right = intervalo.top().second;

23 intervalo.pop();

25 i = left; j = right;

26 auto x = item[(left + right) / 2];

27

28 do {

29 while(comparador(item[i], x) && (i < right)) i++;

30 while(comparador(x, item[j]) && (j > left)) j--;

31

32 if(i<=j) {

33 std::swap(item[i], item[j]);

34 i++; j--;

35 }

36 } while(i<=j);

37

38 if(left<j) intervalo.push_back({left, j});

39 if(i<right) intervalo.push_back({i, right});

40

41 if ((intervalo.size() > 2) && (num_threads < 3) && (princ))

42 {

49

43 auto frente = intervalo.top();

44 num_threads++;

45

46 threads.push_back(std::thread {

47 [frente, &item, &comparador]() {

48 quick(&item[frente.first],

49 frente.second - frente.first + 1,

50 comparador);

51 }

52 });

53 intervalo.pop();

54 }

55 }

56 if (princ)

57 {

58 for (auto &it : threads)

59 if (it.joinable())

60 it.join();

61 }

62 else

63 num_threads--;

64 }

Código-fonte 19 – Versão com o uso de concorrência do algoritmo de ordenação Quicksort.

As alterações iniciam com as inclusões na linha 14 a 17, onde com o objeto

num_threads que é utilizado para controlar o número de threads ativas, essa variável é um

contador compartilhada entre threads, por esse motivo foi escolhida ser do tipo

std::atomic<int>, esse tipo provido pela biblioteca atomic, é utilizado para sincronizar o

uso da variável entre as threads evitando condições de corrida entre os acessos concorrentes.

Após tem-se a declaração da variável princ em que cada thread possuí a sua e que no

representa o “pai” das threads que responsável por criar-lás e após aguardar todas terminarem

o processamento.

Na linha 41 tem-se a criação das threads, para evitar controles mais complexos e

diminuir a chance de erros, foi definido que apenas a thread inicial pode criar outras threads

isto é representado através da variável princ que é verdadeira apenas nela. Foi definido

também o número máximo de threads que podem existir ao mesmo tempo sendo fixado em 4,

contando com a thread principal, que é o número indicado segundo (WILLIANS, 2012) que

define que o número máximo de threads deve ser o dobro dos núcleos do processador. É

50

necessário haver pelo menos 3 intervalos na pilha para que a thread criadora também fique

com conjuntos para processar. Se as condições forem verdadeiras a thread é criada na linha 46

com uma função anônima removendo o intervalo da thread inicial para a nova thread

processar, é incrementado a variável num_threads que controla o número de threads. Ao

terminar o processamento de seu intervalo a thread filha decrementa o contador num_threads

na linha 63 para que se possa criar novas threads respeitando o número máximo, pode-se ver o

uso da variável atômica semelhante a uma variável normal, porém com a vantagem de ser

acessada concorrentemente. Os testes realizados nesta última técnica se encontram na tabela

9.

Característica quick<int> quick<string> quick<pessoa>

V9

Tempo (ms) 266,38 6.904,1 13.172

Desvio Padrão (ms) 23,116 607,75 1.052,5

Variação CV (%) 8,6779 8,8028 7,9905

Tabela 9 – Dados da execução com threads.

Como pode-se ver houve a técnica das threads conseguiu um alto impacto positivo no

desempenho do algoritmo. Devido ao processador dos testes separar em dois núcleos lógicos

a tarefa conseguiu ficar paralela conseguindo assim um ganho significativo na performance da

tarefa. Assim a ordenação ficou concorrente, sendo executada por cada núcleo coordenada

pela thread principal. Nota-se porém que o coeficiente de variância ficou alto, devido as

complexidades e a indeterminação que o sistema operacional agenda a execução das threads

no processador (STROUSTUP, 2013; p. 500). Como nota-se no gráfico 7 comparando a

primeira e última versões do algoritmo:

51

Gráfico 7 - Comparação entre a primeira e última versão do algoritmo.

O aumento de desempenho é fica evidenciado pelo gráfico, onde a versão com o tipo

de dados mais complexos TCC::Pessoa melhorou seu tempo de execução em 58% em relação

com a primeira versão do algoritmo Quicksort. Usando essa decomposição do processamento

dos conjuntos a cada thread e o fato de que os processadores modernos possuem mais de um

núcleo de processamento, aumentou o desempenho do algoritmo mesmo com todos os

controles de concorrência que foram inseridos.

A alteração no código-fonte do algoritmo é de grande impacto, pois necessita de vários

controles de concorrência. Insere pontos de concorrência onde se não corretamente

implantado os controles inserem erros que podem finalizar a aplicação. Prejudica sua

manutenção devido à complexidade para encontrar erros pela indeterminação sequencial que

as operações são executadas. E necessita de uma boa analise para ser implementada, pois tem

de ser uma maneira de subdividir o processamento eficiente entre as threads. Porém as

vantagens em termos de desempenho compensam o uso da técnica de usar threads de

processamento, como se vê nesse caso.

52

5 CONSIDERAÇÕES FINAIS

A análise e otimização de algoritmos podem ser muito úteis em programas que exigem

uma maior eficiência, principalmente quando se trata de programas complexos, longos ou que

contenham muitos dados. O presente trabalho tratou de uma parte conceitual sobre as técnicas

para otimização na linguagem C++, e retratou a aplicação evoluindo o código-fonte com as

otimizações na implantação do algoritmo Quicksort de Schildt (1996).

Inicialmente pode-se citar a utilidade de agregar diversas técnicas em um documento

apenas, pois existem várias técnicas comentadas que propõem aumentar o desempenho,

porém muito fragmentadas e de difícil localização prática e rápida, como já citado por Bulka e

Mayhew (1999).

É claro que se essas técnicas se aplicarem em nível de código, o algoritmo ineficiente

não se tornará ótimo, porém vai tornar o algoritmo mais rápido. Além das técnicas e

estratégias aqui mencionadas uma boa análise no algoritmo é fundamental, para então gerar

aplicativos cada vez mais eficientes. Além disso, a própria linguagem está em constante

evolução com as publicações de novas funcionalidades a cada nova versão da padronização

do C++, então é necessário e interessante novas pesquisas nessa área.

Das técnicas levantadas pela análise de bibliografia, as que se provam como eficientes

pelos autores são: funções com a diretiva inline, generalização por templates, semântica de

movimento, passagem por referência, declaração e inicialização de variáveis. Já, quanto às

outras técnicas: uso de threads e da recursão, foram experimentais para se analisar qual

impacto que estas funções causam no tempo de execução, e foram recomendadas, pois como

demonstrado, ao utilizar a análise para realizar a implementação as técnicas aumentaram em

muito a performance. Chegando ao fruto desta pesquisa no gráfico 8:

53

Gráfico 8 - Comparação do impacto no desempenho de todas as técnicas.

Então, têm-se as técnicas que o programador pode melhorar o desempenho da

aplicação em C++ e também a noção e as maneiras de mensurar o código. Com as técnicas foi

possível mostrar as suas efetividades e como cada uma delas é melhor aplicada, mesmo que o

desempenho caia ao aplicar uma técnica como foi o caso da técnica dos templates no tipo

TCC::Pessoa, deve-se ter o conhecimento técnico para encontrar o ponto que causou, para

assim otimizar a implementação. Assim o programador criar o hábito, sempre que o aplicativo

ficar lento ou não satisfizer suas expectativas, analisar os pontos críticos e identificar se o

problema é no algoritmo, nos pontos “invisíveis” que muitas vezes são inseridos no C++

(como temporários, construtores, etc...). Para que o código em C++ ficar tão rápido como o

código em C, e com uma elegância da linguagem e clareza em seus código-fontes como ficou

evidenciado pelo trabalho realizado.

54

6 REFERÊNCIA BIBLIOGRÁFICA

BULKA, Dov; MAYHEW, David. Efficient C++ Performance Programming Techniques.

Massachusetts, EUA: Addison Wesley, 1999

ECKEL, Bruce. Thinking in C++. Second Edition. New Jersey, EUA. Prentice Hall

Inc, 2000.

FOG, Agner. Optimizing software in C++. Technical University of Denmark. 1998.

GUIMARÃES, Inácio A. Estatística I (Notas de Aulas). Instituto de Ciência Sociais

do Párana. 2002.

KNUTH, Donald E. The Art of Computer Programming: Volume 2, second edition.

Addison Wesley. 1981.

ISENSEE, Pete. C++ Optimization Strategies and Techniques. 2009. Disponível em:

http://www.tantalon.com/pete/cppopt/main.htm. Acesso 16/01/2013 as 22:00.

GOLDTHWAITHE, Lois. STROUSTRUP, Bjarne. Et all. Technical Report on C++

Performance. ISO/IEC TR 18015:2006(E). International Organization for Standardization/

International Electrotechnical Commision. 2006.

JOSUTTIS, Nicolai M. The C++ Standard Library: A Tutorial and Reference. Second

Edition. Addison Wesley. 2012.

KORMANYOS, Christopher M. Real-Time C++: Efficient Object-Oriented and Template

Microcontroller Programming. Springer. 2013.

RECH, Gabriel. GABRIEL, Dimas. Otimização de Código usando o compilador gcc/g++ -

um estudo sobre diretivas de compilação. Universidade de Passo Fundo. 2010.

SCHILDT, Herbert. C Completo e Total. Traduzido em São Paulo, Brasil: Makron Books

LTDA, 1996.

SKIENA. Steven S. The Algorithm Design Manual. Second Edition. Departamento de

Ciência da Computação. Universidade Estadual de Nova Iorque. EUA. Springer, 2009.

STROUSTRUP, Bjarne. The C++ Programming Language. Fourth Edition Texas, EUA:

Addison Wesley, 2013.

SCHILDT, Herbert. C++: The Complete Reference. Third Edition. Osborne McGraw-Hill.

1998.

WILLIANS, Anthony. C++ Concurrency in Action. Pratical Multithreading. Manning

Publications. 2012.

ZAMBERLAN, Elizabete Sarzi. Apostila de Estatística. 2011.