TÉCNICAS DE ANÁLISE E OTIMIZAÇÃO DE CÓDIGO … · RESUMO A otimização de implementações de...
Transcript of TÉCNICAS DE ANÁLISE E OTIMIZAÇÃO DE CÓDIGO … · RESUMO A otimização de implementações de...
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.