Praktické cvičenie: Paralelné počítanie prvočísel
Cieľ
Implementujte výpočet počtu prvočísel v intervale najprv sekvenčne a potom paralelne pomocou viacerých procesov.
Študent si precvičí:
- rozdelenie CPU-bound úlohy na nezávislé časti,
- rozdiel medzi sekvenčným a paralelným riešením,
- čisté funkcie vhodné pre multiprocessing,
- použitie
ProcessPoolExecutor, - skladanie čiastkových výsledkov,
- testovateľný návrh paralelného kódu bez krehkého merania času.
Zadanie
Vytvorte súbor solution.py a implementujte funkcie:
from concurrent.futures import ProcessPoolExecutor
def is_prime(number):
pass
def count_primes_in_range(chunk):
pass
def split_range(start, end, parts):
pass
def count_primes_sequential(start, end):
pass
def count_primes_parallel(start, end, workers=4, executor_class=ProcessPoolExecutor):
pass
Intervaly v tomto zadaní sú polootvorené:
[start, end)
To znamená, že start do intervalu patrí a end už nie.
1. is_prime(number)
Funkcia vráti True, ak je number prvočíslo, inak False.
Príklady:
is_prime(2)
# True
is_prime(17)
# True
is_prime(1)
# False
is_prime(21)
# False
Požiadavky:
- čísla menšie ako 2 nie sú prvočísla,
- netestujte deliteľov väčších ako druhá odmocnina čísla,
- funkcia nemá meniť globálny stav.
2. count_primes_in_range(chunk)
Funkcia dostane dvojicu (start, end) a vráti počet prvočísel v intervale [start, end).
Príklad:
count_primes_in_range((0, 10))
# 4
Prvočísla v intervale [0, 10) sú 2, 3, 5, 7.
Táto funkcia je pracovná jednotka vhodná pre executor.map().
3. split_range(start, end, parts)
Funkcia rozdelí interval [start, end) na najviac parts neprázdnych častí.
Príklad:
split_range(0, 10, 3)
# [(0, 4), (4, 7), (7, 10)]
Požiadavky:
- časti musia pokryť celý interval bez medzier a prekryvov,
- dĺžky častí sa môžu líšiť najviac o 1,
- ak je interval prázdny, vráťte prázdny zoznam,
- ak
parts <= 0, vyhoďteValueError, - ak je
partsväčšie než počet hodnôt v intervale, vráťte iba neprázdne časti.
Príklady:
split_range(5, 5, 4)
# []
split_range(0, 3, 10)
# [(0, 1), (1, 2), (2, 3)]
4. count_primes_sequential(start, end)
Funkcia vráti počet prvočísel v intervale [start, end) sekvenčne v jednom procese.
Príklad:
count_primes_sequential(0, 20)
# 8
Prvočísla menšie ako 20 sú:
2, 3, 5, 7, 11, 13, 17, 19
5. count_primes_parallel(start, end, workers=4, executor_class=ProcessPoolExecutor)
Funkcia vráti počet prvočísel v intervale [start, end) paralelne.
Postup:
- Rozdeľte interval pomocou
split_range(start, end, workers). - Spustite
count_primes_in_rangenad jednotlivými časťami cezexecutor.map(...). - Spočítajte čiastkové výsledky.
Odporúčaná štruktúra:
chunks = split_range(start, end, workers)
with executor_class(max_workers=workers) as executor:
partial_counts = executor.map(count_primes_in_range, chunks)
return sum(partial_counts)
Parameter executor_class nechajte v rozhraní funkcie. Automatické testy vďaka nemu vedia použiť falošný executor a overiť rozdelenie práce bez spúšťania skutočných procesov.
Požiadavky:
- pre prázdny interval vráťte
0, - pre
workers <= 0vyhoďteValueError, - výsledok musí byť rovnaký ako pri
count_primes_sequential, - pracovná funkcia musí byť definovaná na najvyššej úrovni modulu, aby bola použiteľná v multiprocessing.
Automatické hodnotenie
Automatické testy nemajú merať čas. Meranie výkonu je vhodné na demonštráciu, ale v hodnotiacom systéme býva nestabilné. Namiesto toho testy overujú:
- správnosť výpočtu,
- správne rozdelenie intervalov,
- to, že paralelná funkcia používa executor a posiela mu časti práce.
Testy
import pytest
from solution import (
is_prime,
count_primes_in_range,
split_range,
count_primes_sequential,
count_primes_parallel,
)
class FakeExecutor:
instances = []
def __init__(self, max_workers=None):
self.max_workers = max_workers
self.mapped_function = None
self.mapped_items = None
FakeExecutor.instances.append(self)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
return False
def map(self, function, items):
self.mapped_function = function
self.mapped_items = list(items)
return [function(item) for item in self.mapped_items]
def test_is_prime():
primes = [2, 3, 5, 7, 11, 13, 17, 19, 97]
non_primes = [-5, -1, 0, 1, 4, 6, 9, 21, 100]
for number in primes:
assert is_prime(number) is True
for number in non_primes:
assert is_prime(number) is False
def test_count_primes_in_range():
assert count_primes_in_range((0, 10)) == 4
assert count_primes_in_range((10, 20)) == 4
assert count_primes_in_range((20, 30)) == 2
assert count_primes_in_range((30, 30)) == 0
def test_split_range_balanced_chunks():
chunks = split_range(0, 10, 3)
assert chunks == [(0, 4), (4, 7), (7, 10)]
lengths = [end - start for start, end in chunks]
assert max(lengths) - min(lengths) <= 1
def test_split_range_more_parts_than_numbers():
assert split_range(0, 3, 10) == [(0, 1), (1, 2), (2, 3)]
def test_split_range_empty_interval():
assert split_range(5, 5, 4) == []
assert split_range(10, 5, 4) == []
def test_split_range_invalid_parts():
with pytest.raises(ValueError):
split_range(0, 10, 0)
def test_count_primes_sequential():
assert count_primes_sequential(0, 20) == 8
assert count_primes_sequential(100, 130) == 6
assert count_primes_sequential(10, 10) == 0
def test_parallel_matches_sequential_with_fake_executor():
FakeExecutor.instances.clear()
parallel = count_primes_parallel(
0,
100,
workers=4,
executor_class=FakeExecutor,
)
sequential = count_primes_sequential(0, 100)
assert parallel == sequential == 25
executor = FakeExecutor.instances[-1]
assert executor.max_workers == 4
assert executor.mapped_function is count_primes_in_range
assert executor.mapped_items == split_range(0, 100, 4)
def test_parallel_empty_interval_does_not_create_work():
FakeExecutor.instances.clear()
assert count_primes_parallel(10, 10, workers=4, executor_class=FakeExecutor) == 0
assert FakeExecutor.instances == []
def test_parallel_invalid_workers():
with pytest.raises(ValueError):
count_primes_parallel(0, 100, workers=0, executor_class=FakeExecutor)
Skryté testy
Skryté testy môžu overovať:
- väčšie intervaly,
- intervaly začínajúce záporným číslom,
- rôzne počty workerov,
workersväčšie než dĺžka intervalu,- to, že paralelné a sekvenčné riešenie dávajú rovnaký výsledok,
- to, že
count_primes_parallelrešpektuje parameterexecutor_class.
Odporúčaná stratégia riešenia
is_prime: špeciálne ošetrite čísla menšie ako 2, párne čísla a potom skúšajte nepárnych deliteľov po druhú odmocninu.split_range: vypočítajte základnú veľkosť časti cez delenie a zvyšok rozdeľte medzi prvé časti.count_primes_in_range: použitesum(1 for number in range(start, end) if is_prime(number)).count_primes_parallel: neukladajte výsledky do globálnej premennej; každá práca má vrátiť svoje číslo a hlavný proces ich spočíta.
Hodnotenie
| Časť | Body |
|---|---|
is_prime |
3 |
count_primes_in_range |
2 |
split_range |
4 |
count_primes_sequential |
2 |
count_primes_parallel |
5 |
| Ošetrenie prázdnych intervalov a neplatného počtu workerov | 2 |
| Čitateľnosť kódu a čisté funkcie bez zdieľaného stavu | 2 |
Spolu: 20 bodov.
Meranie výkonu na cvičení
Toto nie je vhodné ako automatický test, ale je vhodné na diskusiu počas cvičenia:
import time
start = time.perf_counter()
print(count_primes_sequential(0, 500_000))
print("sequential:", time.perf_counter() - start)
start = time.perf_counter()
print(count_primes_parallel(0, 500_000, workers=4))
print("parallel:", time.perf_counter() - start)
Výsledok závisí od počtu jadier, operačného systému a aktuálneho zaťaženia počítača. Preto sa výkon nemá hodnotiť pevným časovým limitom.
Pri spúšťaní benchmarku zo súboru dajte merací kód pod podmienku:
if __name__ == "__main__":
...
Je to dôležité najmä pri multiprocessing na systémoch, ktoré nové procesy štartujú cez import hlavného modulu.
Bonus: Race condition a Lock
Pridajte funkciu:
def locked_counter(workers, increments_per_worker):
pass
Funkcia vytvorí viac vlákien. Každé vlákno zvýši spoločný čítač increments_per_worker krát. Použite threading.Lock, aby bol výsledok deterministický.
Očakávaný výsledok:
locked_counter(4, 1000)
# 4000
Túto časť je vhodné ukázať aj bez zámku a porovnať správanie, ale do automatického hodnotenia zaraďte iba verziu so zámkom.