Dicas para escrever códigos mais concisos e otimizados

Que o Python é uma linguagem fantástica e simples de trabalhar, todo mundo já sabe. Mas ele pode ser ainda mais legal =D.

Abaixo vou mostrar algumas dicas simples, e que deixarão seu código bem mais conciso e otimizado. Bora ver =).

Evite loops aninhados com função product()

Essa descobri recentemente, e curti demais. Trata-se de uma função built-in do módulo itertools.

As vezes precisamos criar loops aninhados, o que pode deixar nosso código mais lento e difícil de ser lido, e essa função vem para nos ajudar nisso.

Segue um exemplo simples:

def nested_loop():
    start_time = time.perf_counter()
    for a in list_a:
        for b in list_b:
            for c in list_c:
                v = a + b + c
    end_time = time.perf_counter()
    execution_time = end_time - start_time
    print(f">>> Nested loops execution time: {execution_time:.2f}")

Mesmo código, mas agora usando a função product():

def product_function():
    start_time = time.perf_counter()
    from itertools import product
    for a, b, c in product(list_a, list_b, list_c):
        v = a + b + c
    end_time = time.perf_counter()
    execution_time = end_time - start_time
    print(f">>> Product function execution time: {execution_time:.2f}")

Aqui o código completo com teste de performance. Essa performance aumenta conforme a complexidade do que seu código aumenta:

import numpy as np
import time

list_a = np.random.randint(1000, size=300)
list_b = np.random.randint(1000, size=300)
list_c = np.random.randint(1000, size=300)

def nested_loop():
    start_time = time.perf_counter()
    for a in list_a:
        for b in list_b:
            for c in list_c:
                v = a + b + c
    end_time = time.perf_counter()
    execution_time = end_time - start_time
    print(f">>> Nested loops execution time: {execution_time:.2f}")

def product_function():
    start_time = time.perf_counter()
    from itertools import product
    for a, b, c in product(list_a, list_b, list_c):
        v = a + b + c
    end_time = time.perf_counter()
    execution_time = end_time - start_time
    print(f">>> Product function execution time: {execution_time:.2f}")


if __name__ == "__main__":
    nested_loop()
    product_function()

Assignment Expressions

Essa é uma forma bem legal de você atribuir um valor à variável enquanto utiliza-o. Da uma olhada:

O código abaixo est´á sem o uso de expressão de atribuição

var1 = input("Valor 1: ")
var2 = input("Valor 2: ")

if int(var1) ==  int(var2):
    print("Igual")
else:
    print("Diferente")


print(f"Valor 1: {var1}, Valor 2: {var2}")

E agora utilizando expressão de atribuição:

if int(var1 := input("Valor 1: ")) ==  int(var2 := input("Valor 2: ")):
    print("Igual")
else:
    print("Diferente")


print(f"Valor 1: {var1}, Valor 2: {var2}")

Legal né… e vc pode usar de outras formas… por exemplo, em um loop:


while (texto := input("Digite um texto: ")) != "sair":
    print("Texto digitado: ", texto)

Operator condicional ternário, ou simplesmente… if ternário

Para resolver condições simples que envolvam apenas 2 saídas, mais que isso não recomendo o uso de if ternários, pois podem deixar seu código muito difícil de entender:

A sintax é simples:

min = a if a < b else b

Aplicando no if do exemplo anterior, ficaria assim:

print("Igual" if int(var1 := input("Valor 1: ")) ==  int(var2 := input("Valor 2: ")) else "Diferente")
print(f"Valor 1: {var1}, Valor 2: {var2}")

Funções lambda para operações simples

Esse é uma das coisas mais legais do python. A criação de funções anônimas para operações simples. O legal que elas não precisam ser tão anônimas assim, já que conseguimos atribuir uma função lambda a uma variável, e usar essa variável como se fosse uma função definida. Assim:

soma = lambda x,y: x+y
print(soma(1,2))

Outro exemplo legal… uma função para retornar os números da sequência fibonacci

def fib(x):
    if x<=1:
        return x
    else:
        return fib(x-1) + fib(x-2)

Com o uso de lambda:

fib = lambda x: x if x <= 1 else fib(x - 1) + fib(x - 2)

List Comprehensions

Este é sem dúvidas uma das coisas mais legais do Python, a compreensão de listas. É uma forma bem simples e prática de trabalhar com listas.

A sintaxe é simples:

lista_b = [item for item in lista_a [condições] ]

Abaixo um exemplo utilizando list comprehensions com a função lambda que criamos log acima:

fib = lambda x: x if x <= 1 else fib(x - 1) + fib(x - 2)
lista = [fib(i) for i in range(1,11)]

print(lista)

O que fizemos aqui foi simplesmente criar uma lista com 10 números da sequência fibonacci… legal né?! mas da pra fazer muito mais… olha esse outro exemplo:

# Filtrando somente frutas que comecem 
# com a letra M da lista 'frutas'
frutas = ['Banana', 'Maça', 'Pera', 'Abacaxi', 'Uva', 'Melão', 'Melancia', 'Mamão', 'Jaca']
fm = [fruta for fruta in frutas if fruta.startswith('M')]

print(frutas)
print(fm)

Funções de ordem superior

O python possui algumas funções de ordem superior que são verdadeiras jóias quando trabalhamos com dados em larga escala.

Uma função de ordem superior são aquelas funções que recebem outras funções como parâmetros e retornam funções e/ou iterators.

Abaixo um exemplo utilizando a função map():

frutas = ['Banana', 'Maça', 'Pera', 'Abacaxi', 'Uva', 'Melão', 'Melancia', 'Mamão', 'Jaca']
frutas = map(str.upper, frutas)

print(frutas)
# <map object at 0x7f881000cfa0>
print(list(frutas))
#['BANANA', 'MAÇA', 'PERA', 'ABACAXI', 'UVA', 'MELÃO', 'MELANCIA', 'MAMÃO', 'JACA']

Próximo exemplo, aproveitando a lista de frutas do exemplo anterior, vamos ver a função reduce():

r = reduce(lambda x, y: x if len(x) > len(y) else y, frutas)
print(f"{r} é a maior palavra da lista")
# MELANCIA é a maior palavra da lista

Legal né?! 😀

Se você ainda não viu o post sobre Multiprocessing com python, da uma olhada nesse link abaixo:

https://variavelconstante.com.br/2022/04/17/multiprocessing-em-python-extraindo-o-maximo-que-a-sua-maquina-pode-entregar/

Python é fantástico, e isso que eu nem falei sobre generators… mas isso é tema para um próximo post 😀

Abraço!

Fonte: https://medium.com/techtofreedom/9-fabulous-python-tricks-that-make-your-code-more-elegant-bf01a6294908

Como extrair o máximo que a sua máquina pode oferecer!

Recentemente encarei um grande desafio junto com um colega de trabalho, no qual descobrimos o multiprocessing do Python, e o quão poderoso ele é.

Para vocês entenderem melhor o problema, tínhamos alguns scripts em python, que foram escritos para serem executados em servidores on premises, sem computação distribuída, e utilizando a linguagem na sua forma mais básica e pura. Era o que dava pra ser feito com os recursos disponíveis e o cenário da época.

Quando recebemos esse problema, a primeira reação foi: “Cara… isso aqui é trabalho para o Spark… tem que ser reescrito.”. A questão é que não tínhamos tempo para reescrever todos os scripts utilizando a arquitetura que considerávamos a ideal.

Após várias conversas com várias pessoas, todos tínhamos o mesmo sentimento: “Precisamos de uma versão 2.0 onde tudo será reescrito na melhor arquitetura possível”.

Mas e até lá, o que pode ser feito com a versão atual para que rodem num tempo aceitável?!

O quão ruim estava a execução?! 6 scripts com tempos entre 8h30m e 18h. Isso ai, um dos scripts levava 3/4 de um dia inteiro para ser executado.

Esse era o desafio: Melhorar a performance no processamento de vários milhões de registros sem reescrever o código.

Primeiros passos…

Começamos com alguns pequenos refatoramentos no código, com objetivo de deixar mais limpo e na tentativa de liberar memória durante o processamento. Porém isso não surtiu muito efeito.

Segunda ação, foi quebrar o dataset original em datasets menores para serem processados em sequência. Dessa forma ganhamos mais memória para ser usada durante o processamento.

A terceira ação, foi utilizarmos mais maquinas no processamento. Ainda não estamos trabalhando com o processamento distribuido, mas dessa forma conseguimos executar cada dataset em uma maquina e trabalhar com processamento paralelo. Criamos um orquestrador, que ficou responsável por buscar cada dataset, e disparar a chamada para cada script.

E a quarta ação, talvez seja a mais importante delas, e a que me motivou a escrever esse post: A utilização do multiprocessing do python para executar o código em todas as cpus da maquina de forma PARALELA.

Notamos que ao executar o código na sua forma normal, a máquina muitas vezes utilizava apenas 1 core para a execução do processamento, e quando usava mais de 1 core, não fazia de forma paralela.

Após essas 4 ações, conseguimos baixar o tempo de execução de cada script para menos de 20 minutos cada.

IMPORTANTE
Antes de entrar em mais detalhes sobre o multiprocessing, vale lembrar que Multiprocessing É DIFERENTE das threads. Notei que ao conversar sobre Multiprocessing com algumas pessoas, todas citavam as Threads do Python.

Vou deixar dois links bem interessantes que explicam de forma bem simples e prática a diferença entre ambas:

A mágica por trás do multiprocessing

Existe algo chamado Global Interpreter Lock, que é um mecanismo usado pelo CPython(que é a implementação padrão do Python) para garantir que apenas 1 thread por vez execute o Python. Mas por que isso existe? Beeeeeem resumidamente: Para controle de concorrência, dessa forma, ele garante que não terá outros processos tratando os mesmos endereços de memória.

E é ai que entra o Multiprocessing.

Como a própria documentação oficial diz:

“O pacote de multiprocessamento oferece simultaneidade local e remota, efetivamente contornando o Global Interpreter Lock usando subprocessos em vez de threads.”

Python.org

E por que usar o multiprocessing? novamente, a documentação oficial:

“… o módulo de multiprocessamento permite que o programador aproveite totalmente vários processadores em uma determinada máquina”.

Python.org

É isso ai, ele “dibra” o GIL(Global Interpreter Lock) usando subprocessos, dessa forma conseguimos utilizar todos os cpus da maquina de forma PARALELA :D.

Lindo isso né?! 😀

Vou mostrar 2 exemplos bem simples que eu fiz apenas para exemplificar o uso e a execução do multiprocessing.

Análisando a execução do Multiprocessing

A primeira coisa que quero mostrar, é justamente o diferencial dele, a execução em vários processos:

Código normal:

import time
import os

def spawn(num):
    print("PID SPAWN: ", os.getpid(), "NUM: ", num)
    calc = num**2
    
if __name__ == '__main__':
    
    print("PID MAIN: ", os.getpid())
    start = time.time()
    
    for i in range(10):
        spawn(i)

    end = time.time()
    print("Tempo em segundos: ", int(end - start))
Resultado da execução do código acima

Notem que no resultado da execução, tanto o PID principal quanto o PID de cada execução da função spawn são os mesmos, e os números foram executados na ordem.

Código com Multiprocessing utilizando Pool

import multiprocessing
import time
import os

def spawn(num):
    print("PID SPAWN: ", os.getpid(), "NUM: ", num)
    calc = num**2

if __name__ == '__main__':
    
    start = time.time()
    
    print("PID MAIN: ", os.getpid())
    
    with multiprocessing.Pool(processes=8) as pool:
        data = pool.map(spawn, [x for x in range(10)])
    pool.close()
    

    end = time.time()
    print("Tempo em segundos: ", int(end - start))
Resultado da execução utilizando multiprocessing

Notem que agora temos PIDs diferentes, e que os números não estão mais em ordem. Isso é muito legal :D!

Teste de performance

Vamos continuar com os mesmos códigos, mas vamos remover os prints dos PIDs, e vamos aumentar o numero de registros para 100.000.000 com o objetivo de medir em quanto tempo eles serão processados.

Código normal

import multiprocessing
import time
import os

def spawn(num):
    #print("PID SPAWN: ", os.getpid(), "NUM: ", num)
    calc = num**2
    
if __name__ == '__main__':
    
    print("PID MAIN: ", os.getpid())
    start = time.time()
    
    for i in range(100000000):
        spawn(i)

    end = time.time()
    print("Tempo em segundos: ", int(end - start))
Resultado da execução do código normal
Estado dos processadores na execução do código normal

Notem que com o código normal, tivemos 100.000.000 de registros processados em 35 segundos e analisando os processadores, vimos apenas 1 sendo usado 100%.

Código com Multiprocessing utilizando Pool

import multiprocessing
import time
import os

def spawn(num):
    #print("PID SPAWN: ", os.getpid(), "NUM: ", num)
    calc = num**2

if __name__ == '__main__':
    
    start = time.time()
    
    print("PID MAIN: ", os.getpid())
    
    with multiprocessing.Pool(processes=8) as pool:
        data = pool.map(spawn, [x for x in range(100000000)])
    pool.close()
    

    end = time.time()
    print("Tempo em segundos: ", int(end - start))
Resultado da execução com multiprocessing
Estado dos processadores na execução com multiprocessing

Aqui podemos ver bem a diferença, para o mesmo número de registros, a execução levou apenas 19 segundos, e utilizou todos os processadores.

Demais né?! 😀

Porém é importante ressaltar, que se o volume de dados a ser processado não for muito grande, você não conseguirá ver ganhos utilizando o multiprocessing. O real ganho vai aparecendo conforme o volume de dados aumenta.

Conclusão

Bom, esse problema que conseguimos resolver com multiprocessing nos trouxe uma lição muito importante: olhar o hardware.
As vezes não analisamos a infraestrutura que irá suportar os nossos códigos, e quando olhamos, fazemos de forma superficial.

As vezes os problemas não estão no código propriamente dito, mas na forma como ele é executado.

Antes de sair… dá uma conferida na série de posts para o Projeto Video Creator que eu postei aqui no blog. Pra quem curte automatizar o trabalho, vai gostar do conteúdo =D.

Abraços!

Exit mobile version