35 · Paralelismo
3 exercícios: .
python3 exercicios/run_all.py 35Os exercícios#
| # | Título | Enunciado |
|---|---|---|
| 238 | Varios nucleos, de verdade | meca a diferenca entre thread e processo em trabalho de CPU. |
| 239 | O que atravessa para o outro processo | descubra o que viaja junto com a acao, e o que fica. |
| 240 | Um pipeline que usa a maquina inteira | divida, calcule em paralelo, junte — e prove que bate. |
238 · Varios nucleos, de verdade#
Enunciado. meca a diferenca entre thread e processo em trabalho de CPU.
adopt Arcane.Concurrent as P
adopt Arcane.Time as Time
// ── O problema ──────────────────────────────────────────────
//
// 'async', 'thread' e 'parallel' usam threads do Python, e duas
// threads do Python nunca executam bytecode ao mesmo tempo — e o GIL.
//
// trabalho que ESPERA (rede, disco, banco) -> thread
// trabalho que CALCULA (numeros, imagem) -> processo
//
// Para o primeiro caso a thread e perfeita: ela solta o GIL enquanto
// espera, e dez downloads acontecem juntos. Para o segundo, oito
// threads levam o mesmo tempo que uma.
out $"nucleos disponiveis: {P.nucleos()}"
action cpu(n):
soma := 0
cycle i from 1 to n:
soma += i * i
yield soma
steady BLOCOS := [150000, 150000, 150000, 150000]
action cronometrar(f):
inicio := Time.monotonic()
valor := f()
yield {"ms": (Time.monotonic() - inicio) * 1000, "valor": valor}
serie := cronometrar(lambda => [cpu(b) cycle b in BLOCOS])
threads := cronometrar(lambda => P.map(cpu, BLOCOS))
processos := cronometrar(lambda => P.map_processos(cpu, BLOCOS))
// ── O resultado tem de ser o mesmo pelos tres caminhos ──────
assert serie["valor"] is threads["valor"], "as threads mudaram o resultado"
assert serie["valor"] is processos["valor"], "os processos mudaram o resultado"
out $"serie: {round(serie['ms'])} ms"
out $"threads: {round(threads['ms'])} ms"
out $"processos: {round(processos['ms'])} ms"
ganho_threads := serie["ms"] / threads["ms"]
ganho_processos := serie["ms"] / processos["ms"]
out $"threads x{round(ganho_threads, 2)}"
out $"processos x{round(ganho_processos, 2)}"
// ── O que os numeros provam ─────────────────────────────────
//
// A comparacao e sempre com a SERIE medida na mesma maquina. Um
// limite absoluto ("menos de 200 ms") mediria a maquina, e nao o
// paralelismo — e reprovaria num runner ocupado que esta certissimo.
given P.nucleos() >= 4:
assert ganho_processos bigger 1.2,
"os processos nao ganharam da serie: a travessia voltou a rodar num nucleo so"
assert ganho_threads < 1.5,
"as threads ganharam em trabalho de CPU — se o GIL sumiu, este exercicio e que mudou"
out "ok"A pergunta que decide tudo#
O trabalho espera ou calcula?
| O trabalho | Use | Por quê |
|---|---|---|
espera (rede, disco, banco, sleep) | P.map — threads | a thread solta o GIL enquanto espera, e dez downloads acontecem juntos |
| calcula (números, imagem, parsing) | P.map_processos — processos | duas threads do Python nunca executam bytecode ao mesmo tempo |
O GIL — Global Interpreter Lock — é a razão. Ele é do CPython, não do DataForge: async, thread e parallel são threads do Python, e por isso nenhum dos três usa mais de um núcleo para contas.
O que este exercício mede#
Quatro blocos de 150 mil multiplicações, pelos três caminhos:
serie := cronometrar(lambda => [cpu(b) cycle b in BLOCOS])
threads := cronometrar(lambda => P.map(cpu, BLOCOS))
processos := cronometrar(lambda => P.map_processos(cpu, BLOCOS))Numa máquina de 10 núcleos:
| Como | Tempo | Ganho |
|---|---|---|
| em série | 598 ms | — |
P.map — threads | 610 ms | 0,97x |
P.map_processos — processos | 302 ms | 1,98x |
As threads não só deixaram de ganhar: ficaram um pouco mais lentas que a série. É o custo de trocar de contexto sem nada a ganhar em troca — e é o resultado esperado, não um defeito.
Por que a comparação é com a série, e não com um número#
assert ganho_processos bigger 1.2 // certo
assert processos["ms"] < 200 // erradoUm limite absoluto mede a máquina, e não o paralelismo: num runner de CI ocupado, quatro processos perfeitamente paralelos levam mais de 200 ms. A razão contra a série medida na mesma máquina, no mesmo instante, é o que significa alguma coisa.
E a razão é cobrada com fator (bigger 1.2), não com bigger 1: o segundo passa por acidente metade das vezes.
O que o exercício não afirma#
O assert só roda quando há quatro núcleos ou mais:
given P.nucleos() >= 4:
assert ganho_processos bigger 1.2, …Numa máquina de um núcleo, map_processos é mais lento que a série — e está certo. Cobrar ganho ali seria cobrar o impossível.
Onde isso continua#
- `239_o_que_atravessa.df` — o que viaja junto com a
ação, e o que fica para trás
- `240_pipeline_em_blocos.df` — dividir, calcular
em paralelo, juntar, e conferir contra a resposta fechada
239 · O que atravessa para o outro processo#
Enunciado. descubra o que viaja junto com a acao, e o que fica.
adopt Arcane.Concurrent as P
adopt Arcane.Math as M
adopt Arcane.Database as DB
// ── O que viaja ─────────────────────────────────────────────
//
// Um processo recebe o trabalho por COPIA. O que atravessa nao e a
// acao — e a DECLARACAO dela, mais os nomes que ela le e nao cria, e
// tudo o que esses nomes alcancam. Nada disso precisa ser dito.
steady TAXA := 0.08
record Pedido:
cliente: String
valor: Float
enum Faixa:
Comum
Alta
record Nota:
cliente: String
total: Float
faixa: Faixa
action imposto(v):
yield v * TAXA
action faixa_de(v):
yield Faixa.Alta given v bigger 200 otherwise Faixa.Comum
action emitir(p: Pedido) -> Nota:
total := M.round(p.valor + imposto(p.valor), 2)
yield Nota(p.cliente, total, faixa_de(total))
pedidos := [Pedido("ana", 100.0), Pedido("bia", 250.0), Pedido("caio", 30.0)]
notas := P.map_processos(emitir, pedidos)
cycle n in notas:
out n
assert len(notas) is 3, "um item se perdeu na travessia"
assert notas[0].total is 108.0, "a steady TAXA nao atravessou"
assert notas[1].faixa is Faixa.Alta, "o enum nao atravessou"
assert notas[2].faixa is Faixa.Comum, "o enum nao atravessou"
// O tipo que VOLTA e o mesmo declarado aqui — e por isso 'with',
// que confere os campos contra o record, aceita o resultado.
copia := notas[0] with {"cliente": "outro"}
assert copia.cliente is "outro", "o record voltou como outro tipo"
assert copia.total is notas[0].total, "'with' nao preservou o resto"
// ── O que NAO viaja ─────────────────────────────────────────
//
// Uma conexao de banco, um arquivo aberto, um socket, um mutex, um
// canal e uma tarefa existem no processo que os abriu. Copia-los nao
// faria sentido: o outro lado ganharia um descritor que la nao aponta
// para nada.
banco := DB.connect(":memory:")
action usa_o_banco_de_fora(n):
yield len(DB.query(banco, "select 1")) + n
monitor:
P.map_processos(usa_o_banco_de_fora, [1, 2])
assert no, "devia ter recusado"
handle Error as e:
out e.message
assert "banco" in e.message, "a mensagem tem de citar a VARIAVEL pelo nome"
// ── E o que fazer a respeito ────────────────────────────────
//
// Abrir o recurso DENTRO da acao: cada processo abre o seu.
action abre_o_proprio(n):
meu := DB.connect(":memory:")
DB.execute(meu, "create table t (n integer)")
DB.execute(meu, "insert into t values (?)", [n])
yield DB.count(meu, "t") * n
assert P.map_processos(abre_o_proprio, [2, 3, 4]) is [2, 3, 4]
// Um recurso que a acao NAO usa nao atrapalha: a analise so cobra o
// nome quando ele faz falta de verdade.
_trava := P.mutex()
_canal := P.canal()
action nao_usa_nada_de_fora(n):
yield n * 10
assert P.map_processos(nao_usa_nada_de_fora, [1, 2, 3]) is [10, 20, 30]
out "ok"A regra#
Um processo recebe o trabalho por cópia. O que atravessa não é a ação — é a declaração dela, mais os nomes que ela lê e não cria, mais tudo o que esses nomes alcançam.
steady TAXA := 0.08
record Pedido:
cliente: String
valor: Float
action imposto(v):
yield v * TAXA
action emitir(p):
yield Nota(p.cliente, p.valor + imposto(p.valor))
P.map_processos(emitir, pedidos)Atravessam junto, sem que nada disso precise ser dito:
- a
steady TAXA, porqueimpostoa lê - a ação
imposto, porqueemitira chama - os
recorde oenum, porque são construídos lá dentro - o módulo
Arcane.Math— pelo nome: o outro lado o carrega de novo, em vez
de recebê-lo copiado
O record que volta é o mesmo tipo#
copia := notas[0] with {"cliente": "outro"}with confere os campos contra o record. Se o processo filho devolvesse uma cópia do tipo, essa linha recusaria o próprio resultado — e o erro falaria de um Nota que não é o Nota, o que é impossível de entender.
O que não atravessa#
Uma conexão de banco, um arquivo aberto, um socket, um mutex, um canal e uma tarefa existem no processo que os abriu. Copiá-los não faria sentido: o outro lado ganharia um número de descritor que lá não aponta para nada.
erro[DF1001]: 'banco' cannot cross into another process
= nota: it holds a connection to a database, which exists only in
the process that opened it
= dica: open it INSIDE the action — each process opens its own — or
use 'map', which uses threads and shares memoryA mensagem chama a variável pelo nome. Isso importa mais do que parece: a mensagem antiga citava um objeto interno da biblioteca (<locals>.<lambda>) e mandava "declarar a ação no topo do arquivo" — que era onde ela já estava.
A saída: abrir dentro da ação#
action abre_o_proprio(n):
meu := DB.connect(":memory:")
…Cada processo abre o seu. É também o desenho certo para um banco de verdade: uma conexão compartilhada entre processos seria um gargalo, mesmo se pudesse ser copiada.
O que a análise NÃO faz#
Um recurso que a ação não usa não atrapalha:
_trava := P.mutex()
_canal := P.canal()
action nao_usa_nada_de_fora(n):
yield n * 10 // atravessa sem problemaA varredura de nomes livres é generosa de propósito — na dúvida, captura — e por isso um nome que não pôde atravessar só vira erro quando a ação realmente o usa. Reclamar na hora seria falso alarme, e falso alarme ensina a desligar a verificação.
Onde isso continua#
- `238_varios_nucleos.df` — a medida que prova o ganho
- `240_pipeline_em_blocos.df` — o desenho completo
240 · Um pipeline que usa a maquina inteira#
Enunciado. divida, calcule em paralelo, junte — e prove que bate.
adopt Arcane.Concurrent as P
adopt Arcane.Time as Time
// ── O desenho ───────────────────────────────────────────────
//
// dividir -> map_processos -> juntar
//
// Cada bloco tem de ser autossuficiente: ele atravessa para outro
// processo, faz a conta la, e volta com um resumo pequeno. O que
// atravessa custa — mandar o bloco e receber UM numero e barato;
// mandar o bloco e receber outro bloco nao e.
steady TOTAL := 60000
steady BLOCOS := 6
action dividir(quantos, em):
tamanho := quantos ~/ em
yield [{"de":i * tamanho + 1, "ate":(i + 1) * tamanho}
cycle i in range(0, em)]
// O bloco vira um RESUMO, e nao uma lista: e o resumo que volta.
action resumir(bloco):
soma := 0
primos := 0
cycle n from bloco["de"] to bloco["ate"]:
soma += n
given e_primo(n):
primos += 1
yield {"soma": soma, "primos": primos}
action e_primo(n):
given n < 2:
yield no
given n % 2 is 0:
yield n is 2
d := 3
persist d * d <= n:
given n % d is 0:
yield no
d += 2
yield yes
blocos := dividir(TOTAL, BLOCOS)
assert len(blocos) is BLOCOS, "a divisao errou a contagem"
assert blocos[0]["de"] is 1
assert blocos[BLOCOS - 1]["ate"] is TOTAL, "o ultimo bloco nao fecha o total"
inicio := Time.monotonic()
resumos := P.map_processos(resumir, blocos)
ms := (Time.monotonic() - inicio) * 1000
soma := sum([r["soma"] cycle r in resumos])
primos := sum([r["primos"] cycle r in resumos])
out $"{BLOCOS} blocos de {TOTAL ~/ BLOCOS} em {round(ms)} ms"
out $"soma: {soma}"
out $"primos: {primos}"
// ── A conferencia ───────────────────────────────────────────
//
// Um pipeline paralelo que nao e conferido contra a resposta fechada
// e um gerador de numeros plausiveis. A soma de 1 a n tem formula:
esperado := TOTAL * (TOTAL + 1) ~/ 2
assert soma is esperado, $"a soma deu {soma}, e devia ser {esperado}"
assert primos is 6057, "a contagem de primos ate 60000 e conhecida"
// A ordem da entrada e a da saida — sem isso, quem chama teria de
// reassociar bloco e resumo, e e ai que se erra.
assert resumos[0]["soma"] < resumos[BLOCOS - 1]["soma"],
"os resumos voltaram fora de ordem"
out "ok"O desenho#
dividir -> map_processos -> juntarCada bloco tem de ser autossuficiente: ele atravessa para outro processo, faz a conta lá, e volta.
A decisão que mais importa: o que volta#
action resumir(bloco):
soma := 0
primos := 0
cycle n from bloco["de"] to bloco["ate"]:
…
yield {"soma": soma, "primos": primos}O bloco vira um resumo, e não uma lista. Mandar dez mil números e receber dois de volta é barato; mandar dez mil e receber dez mil paga a travessia duas vezes, e aí os processos perdem para a série.
É a mesma conta do map_processos em geral: ele vale a partir de alguns milissegundos de trabalho por item.
A conferência#
Um pipeline paralelo que não é conferido contra uma resposta fechada é um gerador de números plausíveis. Aqui há duas âncoras:
esperado := TOTAL * (TOTAL + 1) ~/ 2
assert soma is esperado
assert primos is 6057 // π(60000), que é uma constante conhecidaA primeira é uma fórmula; a segunda, um valor tabelado. Nenhuma das duas vem do próprio programa — que é o ponto.
A ordem#
assert resumos[0]["soma"] < resumos[BLOCOS - 1]["soma"]map_processos devolve na ordem da entrada, e não na ordem em que os processos terminaram. Sem essa garantia, quem chama teria de reassociar bloco e resumo — e é aí que se erra.
Medido#
Seis blocos de dez mil, numa máquina de 10 núcleos: 426% de CPU. O programa usou quatro núcleos e um pouco, que é o que seis blocos permitem quando cada um custa o mesmo.
Onde isso continua#
- `238_varios_nucleos.df` — a medida do ganho
- `239_o_que_atravessa.df` — o que viaja e o que fica
Rode um isolado com dataforge run exercicios/35-paralelismo/238_varios_nucleos.df.