Acelerando o AdvPL – Parte 02

Introdução

No tópico anterior deste assunto (https://siga0984.wordpress.com/2015/07/16/acelerando-o-advpl-parte-01/), foi abordado um caso de uso de uma função relativamente simples de identificação de linhas de um arquivo texto, onde pequenas alterações no código resultaram em ganhos significativos de desempenho. No post de hoje, vou abordar algumas premissas básicas e comuns em qualquer linguagem de programação, mas com foco no desenvolvimento em AdvPL.

Pontos Comuns de Desempenho

O primeiro e mais comentado paradigma de desempenho é “fazer mais com menos”. Logo, primeiro precisamos entender bem o que deve ser feito. Depois, qual é a forma mais simples de ser feito ( pode haver mais de uma ), e finalmente qual é a forma mais eficiente de ser feito. Acredito que os parágrafos abaixo sejam muito úteis nesta jornada.

1o. Aproveite as funções nativas da linguagem

Com uma linguagem de programação nas mãos, você pode criar quase tudo, inclusive recriar quase tudo. Muitas vezes uma determinada função da linguagem não atende a necessidade, então logo optamos por criar uma nova, escrita usando a própria linguagem. É claro que existem casos onde isto precisa ser feito, porém não se deve tomar por hábito fazer isso a cada desenvolvimento. Existe um overhead entre você chamar uma função da linguagem desenhada para realizar uma tarefa, e a linguagem executar vários statements e operadores para realizar a mesma tarefa.

Exemplo prático : A função AT()

Em AdvPL, temos uma função de busca por substring de texto, chamada AT(). Funções similares existem em diversas linguagens, no grupo de funções de manipulação de String. Basicamente, a função recebe duas strings como argumento, e realiza uma busca da primeira string dentro da segunda, e caso a primeira string seja encontrada inteira dentro da segunda string, a função retorna a posição do primeiro caractere encontrado na string-alvo da busca. Vamos recriar a função At() em Advpl, como sendo uma função estática, para fins de teste.

User Function TSTAT()
Local nI , nTimer
Local cString := "ABRACADABRAPEDECABRAEPAMININONDASEXEMPLOSTRINGLOUCO"
Local nPos1, nPos2
Local nCnt1 := 0
Local nCnt2 := 0 
 
nTimer := seconds()+1
While seconds() < nTimer
 nPos1 := at( "CO" , cString )
 nCnt1++
Enddo
conout("Buscas com AT() = "+cValToChar(nCnt1))
nTimer := seconds()+1
While seconds() < nTimer
 nPos2 := MYat( "CO" , cString )
 nCnt2++
Enddo
conout("Buscas com MYAT() = "+cValToChar(nCnt2))
conout("Confirmando o retorno das funcoes") 
conout("nPos1 = "+cValToChar(nPos1))
conout("nPos2 = "+cValToChar(nPos2))
Return
STATIC Function MyAT(cBusca,cString)
Local nI
Local nL := len(cBusca)
Local nT := len(cString) - nL + 1
For nI := 1 to nT
 If substr(cString,nI,nL) == cBusca
 return nI
 Endif
Next
Return 0

Propositalmente, a string a ser encontrada está no final da string a ser pesquisada, na posição 50. Compile e execute o programa e veja os resultados. Serão mostrados no log de console do TOTVS Application Server a quantidade de buscas realizadas em um segundo. No meu equipamento, os resultados foram:

Buscas com AT() = 1128241
Buscas com MYAT() = 92725
Confirmando o retorno das funcoes
nPos1 = 50
nPos2 = 50

Arredondando os números, a busca com AT() foi mais de 12x mais rápida do que com a MyAT(). Até aqui, tudo se parece muito com o primeiro exemplo mostrado no post anterior, certo? Sim, é isso mesmo. Mas neste post vamos entrar um pouco mais fundo na explicação do “por quê”.

Esta diferença de tempo é justificável ?

SIM. É justificável, e de forma razoavelmente simples. Para a linguagem AdvPL, a chamada da função At() representa uma chamada de uma função nativa do TOTVS Application Server, escrita em C++, que internamente foi desenhada para trabalhar da forma mais optimizada possível. Ao escrevermos uma função AdvPL para fazer o mesmo trabalho, devemos levar em conta que cada atribuição de variável, instrução de loop e incremento, instrução condicional, e demais funções e operadores básicos da linguagem têm um custo, são operações realizadas na camada do AdvPL, que possuem naturalmente o overhead implícito da camada de execução/runtime do AdvPL.

A função MyAT() acaba virando internamente uma sequência de operações, que vai executar a declaração de três variáveis em escopo local, inicializar uma com o retorno da função len() aplicada a um dos parâmetros, inicializar a outra com o resultado da operação matemática do retorno da função len() aplicada ao outro parâmetro, menos o tamanho do parâmetro de busca mais uma unidade, iniciar um laço de repetição (FOR), verificar se o resultado de uma função de extração de uma sequência de caracteres da string-alvo da busca a partir da posição atual é exatamente igual à string de pesquisa, que somente em caso afirmativo interrompe a execução da função, retornando a posição atual onde a string de busca foi encontrada, e retornando 0 quando todas as possibilidades de uma string existir dentro da outra forem verificadas.

Moral da estória: Não reinvente a roda, procure usar funções da linguagem que se aproximem do que você precisa. Noventa e duas mil requisições da função por segundo realmente não é um numero pequeno, porém basta inserir um looping dentro de outro, e um processamento de uma lista de elementos extensa precisar chamar a função mais de 10 milhões de vezes, o que demoraria aproximadamente 108 segundos usando a Myat(), enquanto que a At() demoraria apenas 9 segundos.

2o Em um ambiente distribuído, use a rede com sabedoria

Isto significa “minimizar” o uso da rede. Com o servidor de aplicação sendo executado em uma máquina, um banco de dados relacional em outra, e o servidor de arquivos em outra, cada iteração que usa a rede têm um custo do I/O, independente do tamanho do pacote. Porém, pacotes maiores, próximos ao tamanho do MTU (Maximum Transport Unit) — maior pacote trafegado pela rede sem fragmentação — da sua rede (normalmente de 1480 a 1500 bytes para redes Ethernet / PPPoE) aproveitam melhor a largura de banda da rede. Traduzindo para o português, é mais barato para a rede transportar uma unica requisição de 1024 Bytes do que trafegar 16 requisições de 64 bytes cada.

O mundo ideal é você reaproveitar o que já foi trafegado, por exemplo criando um cache local. Isto pode se aplicar a algumas partes do código, em momentos específicos, e pode ser um pouco mais complexo determinar o que você deve usar em um cache, e por quanto tempo essa informação pode ser mantida em cache antes de ser invalidada. Mas, usada de forma correta, ela pode acelerar incrivelmente a sua aplicação.

Exemplo prático: Consultas ao banco de dados

Em um determinado momento, durante um processamento de uma lista de itens, podem ser necessárias muitas consultas atômicas em tabelas de configuração ou em dados de baixa volatilidade. Quando a incidência de buscas destes dados são realizadas em mais de um momento distinto pela mesma rotina ou por rotinas comuns chamadas várias vezes durante o processo, nada mais elegante do que fazer um cache local dos resultados já obtidos anteriormente, primeiro verificando o dado em cache, e somente indo acessar o dado no SGDB ou no sistema de arquivos remoto se a informação ainda não está cacheada. Muitas aplicações WEB e de processamentos distribuídos utilizam-se destas técnicas, isso dá um fôlego bem maior para o Banco de Dados e para a rede.

Mas lembre-se, cache não é a solução mágica para tudo. Você somente vai ter ganho se houver incidência de buscas por informações já presentes no cache (cache hit). Se todas as suas buscas são diferentes, todos os resultados também serão, e guardar todos eles no cache, sendo que eles não serão pesquisados novamente, somente vai tornar o processamento e o consumo de memória mais acentuados.

E, a coisa se complica quando você decide fazer cache de informações de alta volatilidade, pois se é importante para a sua aplicação sempre pegar a ultima versão de uma informação, que é constantemente atualizada, seu cache precisa ser definido com um curto tempo de validade, pois a sincronização destes dados pela rede pode ter um custo muito alto, para por exemplo um programa notificar os demais para limparem os seus caches sob demanda a cada atualização — é bem mais barato definir um prazo de validade curto e o cache expirar e limpar-se após algum tempo.

Vamos tomar um exemplo, de uma função de processamento hipotética, que faz uma pesquisa em uma tabela para verificar qual é a taxa de juros padrão para cada tipo de contrato, em um momento de recálculo para emissão de boletos de cobrança atrasados com os valores atualizados. Imagine que a função seja a GetJurosPad(), que recebe como parâmetro um código do tipo de contrato, e retorne a taxa de juros padrão para o tipo informado. A consulta é realizada na tabela ZZ1, fazendo uma busca por código e retornando o valor encontrado. Caso o codigo não exista, a função retorna -1

Function GetJurosPad( cTipoCtr )
Local nJuros := -1
ZZ1->(DbSetOrder(1))
If ZZ1->(DbSeek( xFilial('ZZ1')+cTipoCtr ))
 nJuros := ZZ1->Z1_JUROS 
Endif
Return nJuros

Se esta função for chamada 10 mil vezes, cada chamadas desta função, independente do código informado como parâmetro, vão gerar uma requisição via rede ao banco de dados, para trazer o valor atualmente armazenado. Agora, se você tem apenas 10 tipos de contratos diferentes, podemos seguramente fazer um cache na memória, usando um array, para que apenas as requisições não existentes no cache durante o processamento realmente façam um I/O com o Banco de Dados pela rede, e todas as demais 9990 requisições peguem os resultados já armazenados na memória sem usar a rede. E, como as taxas de juros dos tipos de contratos é uma informação que somente é atualizada esporadicamente (baixa volatilidade) e normalmente antes ou depois do expediente, ela se torna um candidato ideal para um cache local.

Vamos fazer um exemplo usando array — pois sabemos que a tabela de taxas de juros é realmente pequena — e vamos usar uma variável STATIC do AdvPL para guardar os resultados, pois durante o processamento esta função pode ser chamada de vários pontos da aplicação.

STATIC aJurosPad := {}
// Funcao de limpeza do cache 
Function ClearJurosPad()
aSize(aJurosPad,0)
return
// Função usando o cache
Function GetJurosPad( cTipoCtr )
Local nJuros := -1
Local cChave := xFilial('ZZ1')+cTipoCtr
Local nPos := ascan(aJurosPad,{ |x| x[1] == cChave })
If nPos > 0 
 // Achou no cache, retorna direto
 Return aJurosPad[nPos][2]
Endif
// Nao achou, busca na tabela 
ZZ1->(DbSetOrder(1))
If ZZ1->(DbSeek( cChave ))
 nJuros := ZZ1->Z1_JUROS 
Endif
// Acrescenta o resultado no cache
aadd(aJurosPad,{cChave,nJuros})
Return nJuros

Para limpar o cache, no inicio e no final do processamento, usamos a função ClearJurosPad(). Durante o processamento, usamos a função GetJurosPad(). Para cada código informado, primeiro será verificado o cache da memória no array STATIC, e caso o código já tenha sido buscado, ele é retornado instantaneamente. Caso contrário, a busca é feita no banco, e sendo encontrado ou não, a resposta da busca é guardada no cache.

Pontos de atenção

O exemplo simplista visto acima é relativamente seguro e performático, dadas as características conhecidas da tabela: Poucos registros, e baixa volatilidade. Caso a tabela possa ter mais registros, este cache pode começar a perder desempenho fazendo busca sequencial no array, sendo mais indicado o uso de um Hash Map, E, a quantidade de elementos deve ser limitada, para não haver um consumo excessivo de memória. Quando o limite estabelecido for atingido, você joga fora uma informação pouco usada do cache, para armazenar uma nova informação.

Sabendo-se que, até 100 registros em cache são aceitáveis fazendo uma busca sequencial no array, podemos limitar o array a 100 elementos, criados sob demanda, e cada busca realizada onde o dado foi encontrado no cache, mas não está na primeira posição, faz o dado encontrado subir no array uma posição. Desse modo, o cache se auto-organiza para ir colocando no topo as buscas mais realizadas, e quando o array atingir 100 posições, uma nova entrada de dados é feita sobrescrevendo a última posição.

STATIC aJurosPad := {}
STATIC nJurosHit := 0
STATIC nJurosMis := 0
// Funcao de limpeza do cache 
Function ClearJurosPad()
aSize(aJurosPad,0)
nJurosHit := 0
nJurosMis := 0
return
// Função usando o cache
Function GetJurosPad( cTipoCtr )
Local nJuros := -1
Local cChave := xFilial('ZZ1')+cTipoCtr
Local nPos := ascan(aJurosPad,{ |x| x[1] == cChave })
Local aTmp
// Codigo revisado em 09/08/2015
// A versao do post original continha um erro de lógica.
// Para ver o que estava de errado e ver o que foi corrigido, acesse o post
// https://siga0984.wordpress.com/2015/08/09/acelerando-o-advpl-parte-02-errata/
If nPos > 0 
 // Achou no cache. 
 nJuros := aJurosPad[nPos][2]
 If nPos > 1 
   // nao esta na primeira posição, sobe uma 
   aTmp := aJurosPad[nPos]
   aJurosPad[nPos] := aJurosPad[nPos-1]
   aJurosPad[nPos-1] := aTmp
 Endif
 // Incrementa cache HITS -- achei no cache, economia de I/O
 nJurosHit++
 Return nJuros
Endif
// Nao achou, busca na tabela 
ZZ1->(DbSetOrder(1))
If ZZ1->(DbSeek( cChave ))
 nJuros := ZZ1->Z1_JUROS 
Endif
// Incrementa cache MISS -- teve que ir pro banco 
nJurosMis++
If len(aJurosPad) < 100
 // Acrescenta o resultado no cache
 aadd(aJurosPad,{cChave,nJuros})
Else
 // Cache cheio, coloca na ultima posição
 aJurosPad[100] := {cChave,nJuros}
Endif
Return nJuros

Com isso, você tem um cache limitado a 100 registros, onde os mais acessados vão sendo trazidos para o topo do array naturalmente, sem precisar fazer reordenação, e de quebra você ainda registra o numero de acertos (hits) e erros (miss) de cache, para poder consultar no final do processo o quanto o seu cache foi eficiente. A taxa de acertos deve ser maior que a de erros. Quanto maior, melhor a eficiência.

E lembre-se de chamar a função de limpeza deste cache antes de iniciar uma sequência de processamentos longa, e de limpar ao final do processamento — pois o seu programa pode permanecer carregado na memória por mais tempo após este processamento, e sem uma limpeza explícita, a memória usada pelo array de cache em uma variável static somente será realizada quando o seu programa for descarregado da memória (terminado).

O teste realizado com as funções acima, no cenário sem cache e com cache, apresentaram uma diferença gritante. A realização de 10 mil buscas aleatórias em registros encontrados na base de dados demoraram por volta de 6 segundos, enquanto a busca utilizando o cache demoraram 0,2 segundos, onde o cache foi alimentado com 200 registros da tabela. Outro ponto interessante do cache, é que a busca em disco faz o posicionamento em um registro e traz o valor de todos os campos, mas como desejamos apenas o valor de um campo do registro, o cache apenas guarda a chave de busca e o valor desejado, minimizando extraordinariamente o consumo de memória.

Conclusão

As boas práticas de desenvolvimento escalar e performático são praticamente as mesmas para qualquer linguagem, antes de mais nada precisamos entender o custo de um processamento, para propor alternativas que o tornem mais leve. Nos próximos tópicos sobre este assunto, vamos abordar alguns usos adicionais e interessantes de mecanismos de cache em processamentos.

Dúvidas, sugestões, reclamações e afins, comente este tópico. Achou este conhecimento útil e interessante ? Faça como eu : Compartilhe 😀

Até o próximo post, pessoal 😉

Anúncios

3 comentários sobre “Acelerando o AdvPL – Parte 02

Deixe um comentário

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

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

Imagem do Twitter

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

Foto do Facebook

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

Foto do Google+

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

Conectando a %s