Esse artigo trata de otimizacoes e outras implementacoes para responder experimentalmente a probabilidade de tirar 3 caras em 5 jogadas de moeda de maneira confiavel que foi implementada em flipCoin3on5_notheoricalknowledge.go.

O problema das moedas é considerado Monte Carlo 101 e a utilizacao de um problema simples permite aprender sobre os temas relacionados a probabilidade para avançar com mais segurança.

Otimizações em flipCoin3on5_notheoricalknowledge.go

Metodo Batch x Incremental

Nosso algoritmo básico consiste em:

  1. Realizar totalSimulacoes de jogar 5X a moeda e tirar 3 caras.
  2. verificar as regras de parada/aceitação do resultado.
  3. se aprovado para o experimento.
  4. se não aprovado itera totalSimulacoes+stepSize e volta ao passo 1.

A cada iteração 1 o algoritmo executa de 0 a totalSimulacoes e por isso ele fica repetindo trabalho.
de 10Milhões de rodadas para 11Milhoes bastaria rodar mais 1 milhão de vezes, mas o algoritmo atual joga fora o conhecimento das 10 milhões de rodadas anteriores e começa do zero, jogando fora 90% do trabalho de executar 11milhões que ja estava realizado.

Vou refatorar para que mude esse comportamento de batch, de executar sempre todo o trabalho, para um comportamento incremental.

O resultado esta no diff do arquivo flibCoinExperiment.go e o impacto foi a redução do tempo de execução de 1m57,856s para 0m3,937s.

Gerador de jogar moeda.

A funcao lancaMoeda deixa claro como o programa funciona. Ela, assim como todo o algoritmo inicial, foi escrita para facilitar a leitura, porém existe espaço para a otimização da operação de lançar a moeda e contar as caras.

A ideia é alterar a maneira como o resultado de lancar a moeda é gerado e como ele é lido pela Rodada.

Utilizando math/rand
Substituir o lancamento da moeda em loop por uma unica chamada.

// 0= coroa, 1=cara 
func lancaMoeda() int{
    return rand.Intn(2)
}

for j := 0; j < lancamentosPorVez; j++ {
        if lancaMoeda() == 1 {
                carasNoExperimento++
        }
}

Vou utilizar math/rand para gerar um numero aleatorio de tamanho 5 contendo 0 |1’s e contar quantos 1 existem nesse numero. É a mesma ideia do loop.

Utilizando o gerador default do go utilize o seguinte código

const lancamentosPorVez = 5
// a operacao bitwise abaixo move lancamentosPorVez vezes o bit
// para a direita:00000001 << 5 = 32 => 00100000
// depois subtrai 1: 00100000 -1 =>= 31 => 00011111
 const mascara = (1 << lancamentosPorVez) -1

// semeia a geração de aleatoriedade. Como ja vimos em outro artigo 
// essa operação não deve ser realizada mais de uma vez.
rand.Seed(time.Now()/UnixNano())

// ao gerar um numero aleatório, digamos: 
// 14 (01110) 
//e aplicar um AND com o valor 00011111 (31 como mascara) 
//estamos forçando uma situacao em que somente os 
//ultimos 5 bits vão fazer parte da resposta (1&1=1, 0&1=0).
// 00001110 &
// 00011111
// 00001110 = 14
jogadas := rand.Uint32() & mascara

// se olhar para os bits temos 5 posicoes 0|1 (coroa|cara).
// se contar a quantidade de 1 como quantidade de caras
carasNoExperimento = bits.OnesCount32(res)
// e comparar com quantidade alvoCaras esperado

 if carasNoExperimento == alvoCaras {                                    
    sucessos++                                                          
 }  

Essa implementação troca 5 iteracoes no loop chamando um rand.Intn por uma única chamada de rand (rand.Uint32).

O tempo de execução foi de 0m3,937s para 0m0,836s

Porém, ainda tem espaço para otimização. O Randomizador default do Go foi desenhado para atender a todo o seu sistema de maneira transparente e para se proteger de concorrencia no caso de uso de rand em goroutines ele utiliza um mutex que da lock no valor. Esse mutex gera um delay sobre o qual voce pode ler mais aqui.

A solução é trocar o rand default por um rand privado, ou seja, um rand que não tem a preocupação de concorrencia e que por isso não utiliza um mutex.

const lancamentosPorVez = 5
const mascara = (1 << lancamentosPorVez) -1

r := rand.New(rand.NewSource(time.Now().UnixNano())

jogadas := r.Uint32() & mascara

carasNoExperimento = bits.OnesCount32(res)
// e comparar com quantidade alvoCaras esperado

 if carasNoExperimento == alvoCaras {                                    
    sucessos++                                                          
 }  

A otimização final fica então de 0m0,836s para 0m0,294s e voce pode ver o codigo nesse commit

Processamento paralelo com goroutine

Refactoring do código para paralelizar o processamento utilizando goroutines.

Sim, goroutines são concorrente por design, mas a ideia aqui não é fazer o processador ficar alterando entre tarefas; a ideia é dividir a tarefa entre os processadores lógicos disponíveis e assim garantir o paralelismo. Isso implica que maquinas com somente um core não irão se beneficiar dessa otimização e por isso deixei ela por ultimo.

Utilizando runtime.NumCPU() vamos saber quantas CPUs logicas temos disponível e a partir dai divido o tamanho da amostra entre os processadores(nao estou tratando o caso de divisao nao exata) e entrego a carga para uma goroutine.

A partir dessa informação, temos:

func rodadaParalela(simToRun int, totalSimulacoes int, sucessos int) (int, int) {
    var wg sync.WaitGroup
    numCPUs := runtime.NumCPU()
    chunks := simToRun / numCPUs
    var sucessosRodada int64 = int64(sucessos)
    for c := 0; c < numCPUs; c++ {                                              
        wg.Add(1)
        go func(n int) {
            defer wg.Done()                                       
            r := rand.New(rand.NewSource(time.Now().UnixNano()))                
            localSucessos := 0
            for i := 0; i < n; i++ {                                            
                res := r.Uint32() & mask
                if bits.OnesCount32(res) == alvoCaras {                         
                    localSucessos++
                }                                                               
            }
            atomic.AddInt64(&sucessosRodada, int64(localSucessos))              
        }(chunks)
    }                                                                           
    wg.Wait()
    return int(sucessosRodada), (totalSimulacoes + simToRun)                    
}

essa otimização reduziu o tempo de processamento de 0m0,294s para 0m0,117s voce pode ver no commit

Removendo Prints
Enviar para stdout também consome um pouco de tempo. Essa otimização reduz de 0m0,117s para 0m0,091s e consiste em imprimir somente o resultado da otimização, removendo os printfs do caminho. Você pode ver o commit aqui

Era para parar por aqui, mas existe o custo de geração do numero randomizado. Acontence que até esse momento estou pagando o custo de gerar o numero e subutilizado o resultado.
Outra questão é que math/rand tem uma versão v2 que é explicada no artigo que já fiz referencia # Evolving the Go Standard Library with math/rand/v2

Espremendo o maximo!!!
Nessa última otimização são utilizados Loop Unrolling e o número randomico gerado é totalmente utilizado, o que significa que ao inves de gerar um número para cada iteração da rodada esse número passa a ser totalmente utilizado, extraindo 12 experimentos de 5 bits a cada vez.

func rodadaParalela(simToRun int, totalSimulacoes int, sucessos int) (int, int) {
    var wg sync.WaitGroup                                                       
    numCPUs := runtime.NumCPU()                                                 

     iteracoesPorCPU := (simToRun / numCPUs) / 12
    var sucessosGlobais int64 = int64(sucessos)

    for c := 0; c < numCPUs; c++ {                                              
        wg.Add(1)                                                               
        go func(n int, id int) {                                                
            defer wg.Done() 

            pcg := rand.NewPCG(uint64(time.Now().UnixNano()), uint64(id+1))     
            r := rand.New(pcg)                                                  

            localSucessos := 0                                                  
            for i := 0; i < n; i++ {                                            
                // Geramos 64 bits de uma vez                                   
                val := r.Uint64()                                               

                // 12 experimentos de 5 bits cada de um único número  
                // redução da carga sobre o gerador aleatório   
                if bits.OnesCount64(val & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 5) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 10) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 15) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 20) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 25) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 30) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 35) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 40) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 45) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 50) & mask) == alvoCaras { localSucessos++ }
                if bits.OnesCount64((val >> 55) & mask) == alvoCaras { localSucessos++ }
            }                                                                   
            atomic.AddInt64(&sucessosGlobais, int64(localSucessos))             
        }(iteracoesPorCPU, c)                                                   
    }                                                                           
    wg.Wait()                                                                   
    return int(sucessosGlobais), int(totalSimulacoes+simToRun)                  
}                                                                   

essa ultima otimização leva o tempo de execução de 0m0,117s para 0m0,041s e voce pode ver o commit aqui.