commit c394677d07cb3fbc0b72b0edec16a0ddc2e24912 Author: Viner Abubakirov Date: Wed Oct 7 13:06:47 2026 +0500 Init diff --git a/README.md b/README.md new file mode 100644 index 0000000..fffced3 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Технология параллельного программирования (Python) + +Курс лабораторных работ для направления «Прикладная математика и информатика». + +## Цель курса + +Научиться осознанно выбирать модель исполнения для конкретной задачи: + +| Модель | Когда помогает | Инструменты | +|---|---|---| +| Последовательный код | базовая линия | stdlib | +| Потоки (threads) | задачи I/O-bound | `threading`, `concurrent.futures` | +| Процессы (processes) | задачи CPU-bound | `multiprocessing` | +| Асинхронность | много I/O-операций, кооперативность | `asyncio` | +| Распределённые вычисления | несколько машин / узлов | `mpi4py` (MS-MPI) | + +Отдельное внимание: **GIL** — почему потоки в CPython не ускоряют чистый Python-код, +и как это обходить. + +## Состав работ + +| № | Папка | Тема | +|---|---|---| +| 0 | `lab00_intro/` | Окружение: Python, venv, VS Code, проверка готовности | +| 1 | `lab01_sequential/` | Последовательное вычисление, профилирование, CPU-bound vs I/O-bound | +| 2 | `lab02_threads/` | Многопоточность на I/O-bound задачах, `ThreadPoolExecutor` | +| 3 | `lab03_gil/` | GIL: почему потоки не ускоряют CPU-код; `hashlib` как исключение | +| 4 | `lab04_multiprocessing/` | Процессы, `Pool`, накладные расходы, spawn на Windows | +| 5 | `lab05_sync/` | Гонки данных, `Lock`, `Semaphore`, `Event`, producer–consumer | +| 6 | `lab06_asyncio/` | `async/await`, event loop, `gather`, семафор, сравнение с потоками | +| 7 | `lab07_comparison/` | Одна задача × 4 подхода, закон Амдала, итоговые графики | +| 8 | `lab08_mpi/` | Распределённые вычисления, MPI (по указанию преподавателя) | + +## Варианты + +Каждый студент выполняет **свой вариант** — см. `common/варианты.md` +(20 вариантов: CPU-задача + параметры + I/O-нагрузка). Ядра всех задач уже +реализованы в `common/tasks.py` — смысл лабораторных именно в *организации +вычислений и замерах*, а не в переписывании алгоритмов. + +## Установка (Windows, 10/11) + +1. Python 3.10+ с python.org (галочка **Add python.exe to PATH**). +2. Открыть папку курса в VS Code (расширение Python). +3. В терминале VS Code: + +```bat +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +python lab00_intro\check_env.py +``` + +Если вывод кириллицы «кракозябрами» — выполните `chcp 65001`. + +На macOS/Linux всё то же самое, кроме `activate` (там `source .venv/bin/activate`). + +## Как сдавать + +- Код: `labXX_.../solution.py` (заполняются заготовки, интерфейсы менять нельзя — + по ним работают автотесты). +- Отчёт: по шаблону `common/шаблон_отчёта.md` — таблицы замеров, графики, выводы. +- Самопроверка перед сдачей: `python -m pytest tests/ -k labXX` из корня курса. +- Чек-лист: `common/чек-лист_сдачи.md`. Типичные ошибки: `common/типичные_ошибки.md`. + +## Структура репозитория + +``` +common/ банк задач, варианты, утилиты замеров, шаблоны +lab00_intro/ ... lab08_mpi/ + методичка.md теория + задание + контрольные вопросы + критерии + examples/ запускаемые демонстрации + solution.py заготовка решения (заполняет студент) +tests/ автотесты pytest (самопроверка студента, проверка преподавателя) +instructor_solutions/ эталонные решения (только для преподавателя) +``` + +## Запуск примеров и решений + +Все команды выполняются **из корня курса**: + +```bat +python lab01_sequential\examples\01_perf_counter_demo.py +python lab01_sequential\solution.py +python -m pytest tests\ -v +``` diff --git a/common/bank_задач.md b/common/bank_задач.md new file mode 100644 index 0000000..3ba1c3e --- /dev/null +++ b/common/bank_задач.md @@ -0,0 +1,41 @@ +# Банк CPU-задач для вариантов + +Все ядра уже реализованы в `common/tasks.py`. Здесь — смысловые описания: +что считается результатом, почему задача CPU-bound и чем интересна при +распараллеливании. Единый интерфейс каждой задачи: + +``` +split(total, parts) -> куски — разбиение работы на части +kernel(кусок) -> частичный результат — вычисление одной части +combine(части) -> итог — сборка итога +``` + +Этот интерфейс намеренно совпадает с типовой схемой MapReduce — он один и тот же +во всех лабораторных: сначала вы вызываете его последовательно, затем потоками, +процессами и т. д. + +| № | Задача | Результат | Почему CPU-bound | Особенности при распараллеливании | +|---|--------|-----------|------------------|-----------------------------------| +| 1 | Умножение матриц (наивное, N×N) | контрольная сумма произведения | тройной цикл на чистом Python | разбиение по строкам; большие данные при пересылке | +| 2 | Число π методом Монте-Карло | приближение π | миллионы случайных бросков | идеально независимые части; суммирование счётчиков | +| 3 | Численное интегрирование (трапеции) | значение интеграла | миллионы вычислений f(x) | разбиение отрезка; сумма частичных площадей | +| 4 | Подсчёт простых чисел ≤ M (перебор делителей) | количество простых | O(N·√N) на чистом Python | разбиение диапазона; сумма счётчиков | +| 5 | Задача N ферзей (подсчёт расстановок) | число решений | комбинаторный перебор | ветка = первый ферзь в колонке; сумма решений | +| 6 | Частотный анализ текста (подсчёт слов) | словарь «слово → число» | построчная обработка миллионов слов | разбиение по строкам; слияние словарей | +| 7 | «Размытие» матрицы (box blur 3×3) | контрольная сумма нового изображения | 9 операций на пиксель на чистом Python | разбиение по строкам с перекрытием (halo) | +| 8 | Хеширование строк (SHA-256) | контрольная сумма дайджестов | сотни тысяч хеширований | независимые элементы; **hashlib освобождает GIL** | +| 9 | Сортировка слиянием больших массивов | отсортированный массив | сортировка миллионов элементов | сортировка кусков + merge; дорогостоящая пересылка данных | +| 10 | Игра «Жизнь» (клеточный автомат) | число живых клеток после T поколений | обход поля на каждое поколение | параллелятся поколения (шаги), обмен каймой строк | + +## Примечания + +- Задачи 1–5, 7, 10 — «честно» последовательные на чистом Python: потоки их + **не** ускоряют (GIL), процессы — ускоряют почти линейно. +- Задача 8 — исключение: `hashlib` освобождает GIL на время хеширования + **одного блока**, поэтому потоки дают реальное ускорение, если блоки + крупные (≥ 256 КБ). На мелких блоках ускорения нет — GIL возвращается + мгновенно. Используется в лабораторной 3 (сравнение 4 КБ против 1 МБ). +- Задача 9 — демонстрация **накладных расходов**: пересылка данных процессам + может съесть весь выигрыш от сортировки. Отличный материал для выводов в отчёте. +- Задача 10 — знакомство с идеей обмена «каймой» (halo exchange), как в + реальных сеточных методах и в MPI. diff --git a/common/benchmark.py b/common/benchmark.py new file mode 100644 index 0000000..189a9ca --- /dev/null +++ b/common/benchmark.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +""" +common.benchmark — утилиты для замеров времени и построения таблиц/графиков. + +Используется во всех лабораторных (1–7), чтобы отчёты были единообразными. + +Пример: + + from common.benchmark import time_call, make_table, save_speedup_plot + + elapsed, result = time_call(lambda: task["kernel"](chunk), repeats=1) + rows = [("последовательно", T1), ("потоки, p=4", T2)] + print(make_table(rows)) +""" +from __future__ import annotations + +import os +import statistics +import time +from typing import Callable, Dict, List, Sequence, Tuple + + +def time_call(fn: Callable, args: tuple = (), repeats: int = 3) -> Tuple[float, any]: + """Замер времени выполнения fn(*args) с повторами. + + Возвращает (лучшее_время_сек, результат). Лучшее (минимальное) время — + стандартная практика: случайные помехи только удлиняют замер. + """ + best = float("inf") + result = None + for _ in range(repeats): + t0 = time.perf_counter() + result = fn(*args) + best = min(best, time.perf_counter() - t0) + return best, result + + +def speedup(T1: float, Tp: float) -> float: + """Ускорение S(p) = T(1) / T(p).""" + if Tp <= 0: + return 0.0 + return T1 / Tp + + +def efficiency(speedup_val: float, p: int) -> float: + """Эффективность E(p) = S(p) / p (доля, а не проценты).""" + if p <= 0: + return 0.0 + return speedup_val / p + + +def amdahl(speedup_bound: float, p: int) -> float: + """Теоретическое ускорение по закону Амдала. + + speedup_bound — доля последовательной части (0..1), т.е. доля кода, + которая не распараллеливается. + """ + s = max(1e-12, speedup_bound) + return 1.0 / (s + (1.0 - s) / p) + + +def make_table(rows: Sequence[Tuple[str, float]], T1: float = None, + title: str = "Замеры") -> str: + """Текстовая таблица: подход | T, c | S(p) | E(p). + + rows — пары (название, время в секундах). T1 — время последовательного + запуска (первая строка, если не задано отдельно). + """ + if T1 is None: + T1 = rows[0][1] + lines = [ + f"{title}", + f"{'подход':<32} {'T, c':>10} {'S(p)':>8} {'E(p)':>8}", + "-" * 62, + ] + for name, t in rows: + s = speedup(T1, t) + p = _p_from_name(name) + e = efficiency(s, p) + lines.append(f"{name:<32} {t:>10.3f} {s:>8.2f} {e:>8.2f}") + return "\n".join(lines) + + +def _p_from_name(name: str) -> int: + """Пытается вытащить число воркеров из названия ('потоки, p=4' -> 4).""" + if "p=" in name: + try: + return int(name.split("p=")[1].split()[0].strip("),")) + except ValueError: + pass + return 1 + + +def save_speedup_plot(results: Dict[str, List[Tuple[int, float]]], title: str, + out_path: str, show: bool = False): + """График ускорения S(p) для нескольких подходов. + + results = {"потоки": [(1, 1.0), (2, 1.9), (4, 3.8)], + "процессы": [(1, 1.0), (2, 1.95), (4, 3.7)]} + + Для каждого подхода S(p) считается относительно его же T(1) — так + кривые корректно сравниваются между собой. + """ + import matplotlib + if not show: + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + plt.figure(figsize=(8, 5)) + for name, pts in results.items(): + pts = sorted(pts) + base = next((t for p, t in pts if p == 1), None) + if base is None: + continue + xs = [p for p, _ in pts] + ys = [base / t for _, t in pts] + plt.plot(xs, ys, marker="o", label=name) + plt.plot(xs, xs, "--", color="gray", label="идеал S(p)=p") + plt.xlabel("число воркеров p") + plt.ylabel("ускорение S(p) = T(1)/T(p)") + plt.title(title) + plt.legend() + plt.grid(True, alpha=0.3) + plt.tight_layout() + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + plt.savefig(out_path, dpi=150) + if show: + plt.show() + plt.close() + print(f"график сохранён: {out_path}") + + +def save_bar_plot(results: Dict[str, float], title: str, out_path: str, + ylabel: str = "время, c", show: bool = False): + """Столбчатая диаграмма: подход -> значение (время или ускорение).""" + import matplotlib + if not show: + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + names = list(results.keys()) + vals = [results[k] for k in names] + plt.figure(figsize=(9, 5)) + plt.bar(names, vals, color="steelblue") + plt.ylabel(ylabel) + plt.title(title) + plt.xticks(rotation=20, ha="right") + plt.grid(True, axis="y", alpha=0.3) + plt.tight_layout() + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + plt.savefig(out_path, dpi=150) + if show: + plt.show() + plt.close() + print(f"график сохранён: {out_path}") + + +def median_time(fn: Callable, args: tuple = (), repeats: int = 5) -> Tuple[float, any]: + """Аналог time_call, но по медиане — устойчив к выбросам.""" + times = [] + result = None + for _ in range(repeats): + t0 = time.perf_counter() + result = fn(*args) + times.append(time.perf_counter() - t0) + return statistics.median(times), result \ No newline at end of file diff --git a/common/tasks.py b/common/tasks.py new file mode 100644 index 0000000..d509217 --- /dev/null +++ b/common/tasks.py @@ -0,0 +1,795 @@ +# -*- coding: utf-8 -*- +""" +common.tasks — ядра CPU-задач и унифицированный доступ к вариантам. + +Все ядра «честно» последовательные на чистом Python (без NumPy), чтобы +эффект GIL и накладные расходы параллелизма были видны честно. + +Единая схема каждой CPU-задачи (MapReduce-подобная): + + data = build(params) # подготовить данные + chunks = split(data, parts) # разбить работу на части + partial = kernel(chunk) # вычислить одну часть + raw = combine(partials) # собрать итог + value = checksum(raw) # свёртка результата для таблиц/сравнений + +Важно: checksum не зависит от способа разбиения — поэтому последовательный и +любой параллельный запуск обязаны давать одинаковый checksum. + +Для multiprocessing функции передавать напрямую нельзя (вложенные функции не +пиккелятся). Используйте dispatch_kernel модульного уровня: + + from common.tasks import dispatch_kernel + with Pool(p) as pool: + partials = pool.map(dispatch_kernel, [(name, ch) for ch in chunks]) + +На верхнем уровне модуля только определения — безопасно для spawn (Windows). +""" +from __future__ import annotations + +import hashlib +import random +import re +import time +from collections import Counter +from typing import Any, Callable, Dict, List, Sequence, Tuple + +# --------------------------------------------------------------------------- +# Реестр задач +# --------------------------------------------------------------------------- + +TASKS: Dict[str, Dict[str, Callable]] = {} + + +def register(name: str): + def wrap(fn): + TASKS[name] = fn() + return fn + return wrap + + +# --------------------------------------------------------------------------- +# Задача 1. Умножение матриц (наивное, чистый Python) +# --------------------------------------------------------------------------- + +@register("matmul") +def _task_matmul(): + def build(params): + n = params["n"] + rng = random.Random(42) + A = [[rng.random() for _ in range(n)] for _ in range(n)] + B = [[rng.random() for _ in range(n)] for _ in range(n)] + return (A, B) + + def split(data, parts): + A, B = data + n = len(A) + bounds = [i * n // parts for i in range(parts + 1)] + return [(A, B, bounds[i], bounds[i + 1]) for i in range(parts) + if bounds[i] < bounds[i + 1]] + + def kernel(chunk): + A, B, i0, i1 = chunk + n = len(A) + # транспонируем B для линейного доступа по памяти (эффект кэша) + Bt = [[B[j][k] for j in range(n)] for k in range(n)] + C = [] + for i in range(i0, i1): + Ai = A[i] + row = [] + for j in range(n): + Bj = Bt[j] + s = 0.0 + for k in range(n): + s += Ai[k] * Bj[k] + row.append(s) + C.append(row) + return (i0, C) + + def combine(partials): + C = [] + for _, rows in sorted(partials, key=lambda r: r[0]): + C.extend(rows) + return C + + def checksum(raw): + s = 0.0 + for row in raw: + for x in row: + s += x + return s + + def params(level): + return {"n": {"S": 140, "M": 180, "L": 220}[level]} + + def describe(params): + return f"умножение матриц {params['n']}x{params['n']}" + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 2. Число π методом Монте-Карло +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Задача 2. Число π методом Монте-Карло +# +# Важный приём: total бросков делится на ФИКСИРОВАННЫЕ серии по SERIES_SIZE +# бросков, у каждой серии своё зерно по её номеру. Кусок = диапазон номеров +# серий. Поэтому checksum не зависит от того, как серии сгруппированы в +# куски — приём, реально применяемый в MC-расчётах для воспроизводимости. +# --------------------------------------------------------------------------- + +@register("pi_mc") +def _task_pi_mc(): + SERIES_SIZE = 10_000 # бросков в серии; серия = единица воспроизводимости + + def build(params): + return params["total"] + + def split(data, parts): + total = data + n_series = total // SERIES_SIZE + bounds = [i * n_series // parts for i in range(parts + 1)] + return [((i, bounds[i], bounds[i + 1]), total) for i in range(parts) + if bounds[i] < bounds[i + 1]] + + def kernel(chunk): + (idx, s0, s1), _total = chunk + inside = 0 + count = 0 + for s in range(s0, s1): + rng = random.Random(s) # зерно = номер серии + for _ in range(SERIES_SIZE): + x = rng.random() + y = rng.random() + if x * x + y * y <= 1.0: + inside += 1 + count += SERIES_SIZE + return (inside, count) + + def combine(partials): + inside = sum(p[0] for p in partials) + total = sum(p[1] for p in partials) + return 4.0 * inside / total + + def checksum(raw): + return raw + + def params(level): + return {"total": {"S": 2_000_000, "M": 4_000_000, "L": 8_000_000}[level]} + + def describe(params): + return f"π методом Монте-Карло, {params['total']:,} бросков".replace(",", " ") + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 3. Численное интегрирование (метод средних прямоугольников) +# --------------------------------------------------------------------------- + +@register("integrate") +def _task_integrate(): + def f(x): + return 4.0 / (1.0 + x * x) # на [0, 1] интеграл = π (проверяемый ответ) + + def build(params): + return params + + def split(data, parts): + a, b, n = data["a"], data["b"], data["n"] + # кусок = диапазон ИНДЕКСОВ отрезков [i0, i1): kernel считает всегда + # одни и те же отрезки, checksum не зависит от разбиения + bounds = [i * n // parts for i in range(parts + 1)] + return [(data, bounds[i], bounds[i + 1]) for i in range(parts) + if bounds[i] < bounds[i + 1]] + + def kernel(chunk): + data, i0, i1 = chunk + a, b, n = data["a"], data["b"], data["n"] + h = (b - a) / n + s = 0.0 + for i in range(i0, i1): + s += f(a + (i + 0.5) * h) + return s * h + + def combine(partials): + return sum(partials) + + def checksum(raw): + return raw + + def params(level): + n = {"S": 3_000_000, "M": 6_000_000, "L": 10_000_000}[level] + return {"a": 0.0, "b": 10.0, "n": n} + + def describe(params): + n_str = f"{params['n']:,}".replace(",", " ") + return (f"интегрирование [{params['a']:g}, {params['b']:g}] " + f"{n_str} отрезков") + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 4. Подсчёт простых чисел ≤ M (перебор делителей) +# --------------------------------------------------------------------------- + +@register("primes") +def _task_primes(): + def _is_prime(k): + if k < 2: + return False + if k % 2 == 0: + return k == 2 + d = 3 + while d * d <= k: + if k % d == 0: + return False + d += 2 + return True + + def build(params): + return params["M"] + + def split(data, parts): + M = data + lo = 2 + span = (M + 1 - lo) // parts + if span == 0: + span = 1 + bounds = [lo + i * span for i in range(parts)] + [M + 1] + return [(bounds[i], min(bounds[i + 1], M + 1)) for i in range(parts) + if bounds[i] < min(bounds[i + 1], M + 1)] + + def kernel(chunk): + lo, hi = chunk + c = 0 + for k in range(lo, hi): + if _is_prime(k): + c += 1 + return c + + def combine(partials): + return sum(partials) + + def checksum(raw): + return raw + + def params(level): + return {"M": {"S": 1_000_000, "M": 2_000_000, "L": 4_000_000}[level]} + + def describe(params): + return f"подсчёт простых чисел ≤ {params['M']:,}".replace(",", " ") + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 5. Задача N ферзей (подсчёт расстановок) +# --------------------------------------------------------------------------- + +@register("nqueens") +def _task_nqueens(): + def build(params): + return params["N"] + + def split(data, parts): + N = data + # одна ветка = первый ферзь в колонке col + return [(N, col) for col in range(N)] + + def kernel(chunk): + N, col0 = chunk + count = 0 + cols = [-1] * N + + def place(row): + nonlocal count + if row == N: + count += 1 + return + start = col0 if row == 0 else 0 + for col in range(start, N): + ok = True + for r in range(row): + c = cols[r] + if c == col or abs(c - col) == row - r: + ok = False + break + if ok: + cols[row] = col + place(row + 1) + cols[row] = -1 + + place(0) + return count + + def combine(partials): + return sum(partials) + + def checksum(raw): + return raw + + def params(level): + return {"N": {"S": 10, "M": 11, "L": 12}[level]} + + def describe(params): + return f"задача {params['N']} ферзей, подсчёт расстановок" + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 6. Частотный анализ текста +# --------------------------------------------------------------------------- + +_WORD_RE = re.compile(r"[а-яёa-z0-9]+") + +_SENTENCES = [ + "параллельное программирование это просто", + "потоки и процессы работают по разному", + "гил ограничивает настоящую параллельность потоков", + "асинхронный код ждёт ввод вывод эффективно", + "метод монте карло считает число пи", + "распределённые системы соединяют много машин", + "синхронизация защищает общие данные", + "очередь сообщений соединяет производителя и потребителя", + "каждый воркер обрабатывает свой кусок работы", + "ускорение зависит от доли последовательного кода", +] + + +@register("wordcount") +def _task_wordcount(): + def build(params): + lines = params["lines"] + rng = random.Random(12345) + words_pool = [w for s in _SENTENCES for w in s.split()] + out = [] + for _ in range(lines): + k = rng.randint(5, 20) + out.append(" ".join(rng.choice(words_pool) for _ in range(k))) + return out + + def split(data, parts): + lines = data + n = len(lines) + bounds = [i * n // parts for i in range(parts + 1)] + return [lines[bounds[i]:bounds[i + 1]] for i in range(parts) + if bounds[i] < bounds[i + 1]] + + def kernel(chunk): + c = Counter() + for line in chunk: + for w in _WORD_RE.findall(line): + c[w] += 1 + return c + + def combine(partials): + total = Counter() + for c in partials: + total.update(c) + return total + + def checksum(raw): + # полная сумма вхождений — не зависит от разбиения + return sum(raw.values()) + + def params(level): + return {"lines": {"S": 100_000, "M": 200_000, "L": 400_000}[level]} + + def describe(params): + return f"частотный анализ, {params['lines']:,} строк".replace(",", " ") + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 7. Box blur (размытие матрицы, чистый Python, кайма 1 строка) +# --------------------------------------------------------------------------- + +@register("blur") +def _task_blur(): + def build(params): + size = params["size"] + rng = random.Random(777) + return [[rng.random() for _ in range(size)] for _ in range(size)] + + def split(data, parts): + img = data + n = len(img) + bounds = [i * n // parts for i in range(parts + 1)] + chunks = [] + for i in range(parts): + i0, i1 = bounds[i], bounds[i + 1] + if i0 >= i1: + continue + top = max(0, i0 - 1) + bot = min(n, i1 + 1) + sub = [row[:] for row in img[top:bot]] # копия с каймой (halo) + chunks.append((sub, i0 - top, i1 - top)) + return chunks + + def kernel(chunk): + sub, lo, hi = chunk # вычисляем строки [lo, hi) внутри sub + m = len(sub[0]) + out = [] + for r in range(lo, hi): + new_row = [] + for c in range(m): + s = 0.0 + cnt = 0 + for dr in (-1, 0, 1): + rr = r + dr + if 0 <= rr < len(sub): + row = sub[rr] + for dc in (-1, 0, 1): + cc = c + dc + if 0 <= cc < m: + s += row[cc] + cnt += 1 + new_row.append(s / cnt) + out.append(new_row) + return (lo, out) + + def combine(partials): + out = [] + for _, rows in sorted(partials, key=lambda r: r[0]): + out.extend(rows) + return out + + def checksum(raw): + s = 0.0 + for row in raw: + for x in row: + s += x + return s + + def params(level): + return {"size": {"S": 600, "M": 900, "L": 1200}[level]} + + def describe(params): + return f"box blur {params['size']}x{params['size']}" + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 8. Хеширование строк SHA-256 +# --------------------------------------------------------------------------- + +@register("hashing") +def _task_hashing(): + def build(params): + lines = params["lines"] + rng = random.Random(555) + pool = [w for s in _SENTENCES for w in s.split()] + out = [] + for _ in range(lines): + k = rng.randint(30, 80) + out.append(" ".join(rng.choice(pool) for _ in range(k)).encode("utf-8")) + return out + + def split(data, parts): + items = data + n = len(items) + bounds = [i * n // parts for i in range(parts + 1)] + return [items[bounds[i]:bounds[i + 1]] for i in range(parts) + if bounds[i] < bounds[i + 1]] + + def kernel(items): + # свёртка по каждому элементу отдельно => результат не зависит + # от границ кусков. hashlib отпускает GIL на время одного вызова: + # на коротких строках эффект незаметен, на блоках ≥ 256 КБ потоки + # реально параллелятся (демо в лабе 3) + acc = 0 + for it in items: + d = hashlib.sha256(it).digest() + acc = (acc + int.from_bytes(d[:8], "big")) & 0xFFFFFFFFFFFFFFFF + return acc + + def combine(partials): + acc = 0 + for v in partials: + acc = (acc + v) & 0xFFFFFFFFFFFFFFFF + return acc + + def checksum(raw): + return raw + + def params(level): + return {"lines": {"S": 300_000, "M": 600_000, "L": 1_200_000}[level]} + + def describe(params): + return f"хеширование SHA-256, {params['lines']:,} строк".replace(",", " ") + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 9. Сортировка слиянием (куски + k-way merge) +# --------------------------------------------------------------------------- + +@register("sortbig") +def _task_sortbig(): + def build(params): + n = params["n"] + rng = random.Random(999) + return [rng.random() for _ in range(n)] + + def split(data, parts): + arr = data + n = len(arr) + bounds = [i * n // parts for i in range(parts + 1)] + return [arr[bounds[i]:bounds[i + 1]] for i in range(parts) + if bounds[i] < bounds[i + 1]] + + def kernel(chunk): + return sorted(chunk) + + def combine(partials): + import heapq + return list(heapq.merge(*partials)) + + def checksum(raw): + s = 0.0 + prev = -1.0 + for x in raw: + if x < prev: + raise ValueError("массив не отсортирован") + s += x + prev = x + return s + + def params(level): + return {"n": {"S": 1_000_000, "M": 2_000_000, "L": 4_000_000}[level]} + + def describe(params): + return f"сортировка слиянием {params['n']:,} чисел".replace(",", " ") + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# Задача 10. Игра «Жизнь» (параллелятся строки одного поколения) +# --------------------------------------------------------------------------- + +@register("life") +def _task_life(): + def build(params): + size = params["size"] + rng = random.Random(31337) + grid = [[1 if rng.random() < 0.30 else 0 for _ in range(size)] + for _ in range(size)] + return (grid, params["steps"]) + + def split(data, parts): + grid, _steps = data + n = len(grid) + bounds = [i * n // parts for i in range(parts + 1)] + return [(grid, bounds[i], bounds[i + 1]) for i in range(parts) + if bounds[i] < bounds[i + 1]] + + def kernel(chunk): + # ОДНО поколение для строк [i0, i1). Соседние строки читаются, свои — пишутся. + grid, i0, i1 = chunk + m = len(grid[0]) + band = [] + for r in range(i0, i1): + new_row = [0] * m + for c in range(m): + s = 0 + for dr in (-1, 0, 1): + rr = r + dr + if 0 <= rr < len(grid): + row = grid[rr] + for dc in (-1, 0, 1): + if dc == 0 and dr == 0: + continue + cc = c + dc + if 0 <= cc < m: + s += row[cc] + alive = grid[r][c] == 1 + if alive and (s == 2 or s == 3): + new_row[c] = 1 + elif not alive and s == 3: + new_row[c] = 1 + band.append(new_row) + return (i0, band) + + def combine(partials): + grid = [] + for _, rows in sorted(partials, key=lambda r: r[0]): + grid.extend(rows) + return grid + + def checksum(raw): + return sum(sum(row) for row in raw) + + def params(level): + size = {"S": 500, "M": 600, "L": 700}[level] + steps = {"S": 20, "M": 25, "L": 30}[level] + return {"size": size, "steps": steps} + + def describe(params): + return (f"игра «Жизнь» {params['size']}x{params['size']}, " + f"{params['steps']} поколений") + + return dict(build=build, split=split, kernel=kernel, combine=combine, + checksum=checksum, params=params, describe=describe) + + +# --------------------------------------------------------------------------- +# I/O-нагрузка (общая для всех вариантов, имитация сетевых запросов) +# --------------------------------------------------------------------------- + +def io_fetch(item: int, delay: float = 0.05) -> Tuple[int, int]: + """Имитация I/O-операции: блокирующая задержка + «полезная работа». + + Никаких настоящих сетевых вызовов — только time.sleep (блокирующий). + Это позволяет сравнивать threading / multiprocessing / asyncio на равных. + """ + time.sleep(delay) + return (item, item * item) + + +def io_items(count: int) -> List[int]: + return list(range(count)) + + +# --------------------------------------------------------------------------- +# Вызов kernel из процессов (модульного уровня — пиккелится) +# --------------------------------------------------------------------------- + +def dispatch_kernel(payload: Tuple[str, Any]): + """payload = (имя_задачи, chunk) -> частичный результат. + + Pool не умеет передавать вложенные функции, поэтому dispatch живёт + на уровне модуля и диспетчеризует по имени задачи. + """ + name, chunk = payload + return TASKS[name]["kernel"](chunk) + + +# --------------------------------------------------------------------------- +# Последовательный запуск (эталон для сравнения и проверки) +# --------------------------------------------------------------------------- + +def run_sequential(task_name: str, params: dict, parts: int = 1): + """Последовательное выполнение задачи. Возвращает (checksum, секунды). + + parts > 1 означает: те же куски, что пошли бы воркерам, но выполняются + в главном процессе по очереди. Используется, чтобы проверить, что + разбиение не меняет результат. + """ + task = TASKS[task_name] + data = task["build"](params) + t0 = time.perf_counter() + if task_name == "life": + grid, steps = data + for _ in range(steps): + chunks = task["split"]((grid, steps), parts) + partials = [task["kernel"](ch) for ch in chunks] + grid = task["combine"](partials) + raw = grid + else: + chunks = task["split"](data, parts) + partials = [task["kernel"](ch) for ch in chunks] + raw = task["combine"](partials) + elapsed = time.perf_counter() - t0 + return task["checksum"](raw), elapsed + + +# --------------------------------------------------------------------------- +# Известные ответы (для автотестов) +# --------------------------------------------------------------------------- + +_NQUEENS_KNOWN = {4: 2, 5: 10, 6: 4, 7: 40, 8: 92, 9: 352, + 10: 724, 11: 2680, 12: 14200} + + +def known_checksum(task_name: str, params: dict): + """Известный правильный ответ, если он есть, иначе None. + + Для pi_mc и integrate ответ известен математически (π), но с ограниченной + точностью — их проверяют автотесты отдельно. + """ + import math + if task_name == "nqueens": + return _NQUEENS_KNOWN.get(params["N"]) + if task_name == "primes": + M = params["M"] + sieve = bytearray([1]) * (M + 1) + sieve[0:2] = b"\x00\x00" + for p in range(2, int(M ** 0.5) + 1): + if sieve[p]: + sieve[p * p:: p] = bytearray(len(sieve[p * p:: p])) + return int(sum(sieve)) + if task_name == "integrate" and params["a"] == 0.0 and params["b"] == 1.0: + return math.pi + if task_name == "pi_mc": + return math.pi # приближённо, тест сравнит с допуском + return None + + +# --------------------------------------------------------------------------- +# «Дымовые» параметры (маленькие — для автотестов и самопроверки) +# --------------------------------------------------------------------------- + +_SMOKE = { + "matmul": {"n": 12}, + "pi_mc": {"total": 50_000}, # кратно SERIES_SIZE=10_000 + "integrate": {"a": 0.0, "b": 1.0, "n": 20_000}, + "primes": {"M": 2_000}, + "nqueens": {"N": 6}, + "wordcount": {"lines": 2_000}, + "blur": {"size": 40}, + "hashing": {"lines": 5_000}, + "sortbig": {"n": 20_000}, + "life": {"size": 20, "steps": 3}, +} + + +def smoke_params(task_name: str) -> dict: + return dict(_SMOKE[task_name]) + + +# --------------------------------------------------------------------------- +# Варианты +# --------------------------------------------------------------------------- + +_VARIANTS: Dict[int, Tuple[str, str]] = { + 1: ("matmul", "S"), + 2: ("pi_mc", "M"), + 3: ("integrate", "L"), + 4: ("primes", "S"), + 5: ("nqueens", "M"), + 6: ("wordcount", "L"), + 7: ("blur", "S"), + 8: ("hashing", "M"), + 9: ("sortbig", "L"), + 10: ("life", "S"), + 11: ("matmul", "M"), + 12: ("pi_mc", "L"), + 13: ("integrate", "S"), + 14: ("primes", "M"), + 15: ("nqueens", "L"), + 16: ("wordcount", "S"), + 17: ("blur", "M"), + 18: ("hashing", "L"), + 19: ("sortbig", "S"), + 20: ("life", "M"), +} + +_IO_BY_LEVEL = {"S": (24, 0.05), "M": (48, 0.05), "L": (96, 0.05)} + + +def get_variant(num: int) -> Dict[str, Any]: + """Параметры варианта по номеру студента в журнале (1–20).""" + if not 1 <= num <= 20: + raise ValueError("номер варианта должен быть от 1 до 20") + task_name, level = _VARIANTS[num] + task = TASKS[task_name] + cpu_params = task["params"](level) + io_items_n, io_delay = _IO_BY_LEVEL[level] + return { + "variant": num, + "task_name": task_name, + "level": level, + "cpu_params": cpu_params, + "io": {"items": io_items_n, "delay": io_delay}, + "p_list": [1, 2, 4] if level in ("S", "M") else [1, 2, 4, 8], + "describe": task["describe"](cpu_params), + } \ No newline at end of file diff --git a/common/варианты.md b/common/варианты.md new file mode 100644 index 0000000..1d992ad --- /dev/null +++ b/common/варианты.md @@ -0,0 +1,66 @@ +# Варианты заданий (20 вариантов) + +Вариант = номер студента в журнале (1–20). Если студентов больше 20 — вариант +`N` = `(N - 1) % 20 + 1`. + +Каждый вариант состоит из: + +1. **CPU-задача** (из `bank_задач.md`) с размером данных по уровню S/M/L; +2. **I/O-нагрузка** — одинаковая для всех по смыслу: `N_items` «запросов», + каждый имитируется задержкой `delay = 0.05 c` (`common/tasks.py: io_fetch`); +3. **Список p** — количества потоков/процессов для замеров T(p). + +| Вариант | Задача | Уровень | Размер данных | I/O (items × delay) | p для замеров | +|--------:|--------|:-------:|---------------|:-------------------:|:-------------:| +| 1 | 1. Умножение матриц | S | n = 140 | 24 × 0.05 c | 1, 2, 4 | +| 2 | 2. π методом Монте-Карло | M | 4 000 000 бросков | 48 × 0.05 c | 1, 2, 4 | +| 3 | 3. Интегрирование | L | 10 000 000 отрезков, [0, 10] | 96 × 0.05 c | 1, 2, 4, 8 | +| 4 | 4. Простые числа | S | M = 1 000 000 | 24 × 0.05 c | 1, 2, 4 | +| 5 | 5. N ферзей | M | N = 11 | 48 × 0.05 c | 1, 2, 4 | +| 6 | 6. Частотный анализ | L | 400 000 строк | 96 × 0.05 c | 1, 2, 4, 8 | +| 7 | 7. Размытие (box blur) | S | 600 × 600 | 24 × 0.05 c | 1, 2, 4 | +| 8 | 8. Хеширование SHA-256 | M | 600 000 строк | 48 × 0.05 c | 1, 2, 4 | +| 9 | 9. Сортировка слиянием | L | 4 000 000 чисел | 96 × 0.05 c | 1, 2, 4, 8 | +| 10 | 10. Игра «Жизнь» | S | 500 × 500, 20 поколений | 24 × 0.05 c | 1, 2, 4 | +| 11 | 1. Умножение матриц | M | n = 180 | 48 × 0.05 c | 1, 2, 4 | +| 12 | 2. π методом Монте-Карло | L | 8 000 000 бросков | 96 × 0.05 c | 1, 2, 4, 8 | +| 13 | 3. Интегрирование | S | 3 000 000 отрезков, [0, 10] | 24 × 0.05 c | 1, 2, 4 | +| 14 | 4. Простые числа | M | M = 2 000 000 | 48 × 0.05 c | 1, 2, 4 | +| 15 | 5. N ферзей | L | N = 12 | 96 × 0.05 c | 1, 2, 4, 8 | +| 16 | 6. Частотный анализ | S | 100 000 строк | 24 × 0.05 c | 1, 2, 4 | +| 17 | 7. Размытие (box blur) | M | 900 × 900 | 48 × 0.05 c | 1, 2, 4 | +| 18 | 8. Хеширование SHA-256 | L | 1 200 000 строк | 96 × 0.05 c | 1, 2, 4, 8 | +| 19 | 9. Сортировка слиянием | S | 1 000 000 чисел | 24 × 0.05 c | 1, 2, 4 | +| 20 | 10. Игра «Жизнь» | M | 700 × 700, 30 поколений | 48 × 0.05 c | 1, 2, 4 | + +## Уровни размеров (справочно) + +| Задача | S | M | L | +|--------|---|---|---| +| 1. Матрицы | 140 | 180 | 220 | +| 2. π Монте-Карло | 2 000 000 | 4 000 000 | 8 000 000 | +| 3. Интегрирование | 3 000 000 | 6 000 000 | 10 000 000 | +| 4. Простые числа | 1 000 000 | 2 000 000 | 4 000 000 | +| 5. N ферзей | 10 | 11 | 12 | +| 6. Частотный анализ | 100 000 | 200 000 | 400 000 | +| 7. Box blur | 600 | 900 | 1200 | +| 8. Хеширование | 300 000 | 600 000 | 1 200 000 | +| 9. Сортировка | 1 000 000 | 2 000 000 | 4 000 000 | +| 10. «Жизнь» | 500×500, 20 пок. | 700×700, 30 пок. | 900×900, 40 пок. | + +## Как получить свои параметры в коде + +```python +from common.tasks import get_variant + +v = get_variant(13) # ваш номер варианта +print(v["task_name"]) # "integrate" +print(v["cpu_params"]) # {"a": 0.0, "b": 10.0, "n": 3_000_000} +print(v["io"]) # {"items": 24, "delay": 0.05, "concurrency": 4} +``` + +## Правило добросовестности + +Параметры варианта менять нельзя (иначе сравнение T(p) между студентами теряет +смысл). Если ваша машина слишком медленная для уровня L — согласуйте с +преподавателем переход на уровень M и отразите это в отчёте. diff --git a/common/типичные_ошибки.md b/common/типичные_ошибки.md new file mode 100644 index 0000000..e453ef9 --- /dev/null +++ b/common/типичные_ошибки.md @@ -0,0 +1,109 @@ +# Типичные ошибки и как их избежать + +Сборник реальных проблем, с которыми сталкиваются студенты на этом курсе. +Прочитайте перед началом работы — сэкономит часы отладки. + +## 1. `multiprocessing` на Windows: `BrokenProcessPool` / бесконечный цикл + +**Симптом:** при запуске кода с `Pool` или `Process` на Windows процесс +перезапускается бесконечно или падает с ошибкой пиккелинга. + +**Причина:** Windows использует метод запуска `spawn` — каждый дочерний +процесс заново импортирует ваш модуль. Если создание воркеров не защищено, +оно повторяется в каждом импорте. + +**Лечение:** весь код создания процессов — только под +`if __name__ == "__main__":`. Это правило №1 курса. + +```python +def main(): + with Pool(4) as pool: + ... + +if __name__ == "__main__": + main() +``` + +## 2. «Мой параллельный код медленнее последовательного» + +**Причины по частоте:** +1. Накладные расходы (запуск процесса ~10–100 мс, передача данных через + pickle) больше, чем выгода, — работа слишком мелкая. +2. GIL: потоки на CPU-bound задаче не ускоряют, а только добавляют + переключения. +3. Слишком много частей: 10 000 задач по 1 мс — плохо; 4–8 частей по + 100 мс — хорошо. + +**Что делать:** это не ошибка, а результат для анализа. Опишите его в отчёте — +именно это и является целью курса. + +## 3. Результат параллельного запуска не совпадает с последовательным + +**Причины:** +- результат зависит от порядка сборки частичных результатов; +- несколько воркеров изменяют общую структуру без синхронизации (гонка); +- случайные данные без фиксированного seed. + +**Лечение:** в этом курсе все задачи устроены так, что checksum не зависит от +разбиения. Если не совпадает — ищите гонку или неверную сборку. + +## 4. Дедлок в producer–consumer + +**Симптом:** программа висит и не завершается. + +**Частые причины:** +- потребитель ждёт `queue.get()` в бесконечном цикле, а производитель уже + закончил — используйте `queue.put(None)` как «маркер конца» или + `queue.close()` + `join_thread()`; +- `pool.apply()` без закрытия пула; +- ожидание события, которое никто не установит. + +**Отладка:** добавьте print перед/после каждого get/put — сразу видно, кто +кого не дождался. + +## 5. `asyncio.run()` нельзя вложить + +**Симптом:** `RuntimeError: asyncio.run() cannot be called from a running +event loop`. + +**Причина:** попытка запустить event loop внутри уже работающего (например, +из Jupyter). + +**Лечение:** один `asyncio.run(main())` на программу, вызывается из обычного +синхронного кода. + +## 6. Забыли `await` + +**Симптом:** `RuntimeWarning: coroutine ... was never awaited`, код +выполняется мгновенно и ничего не делает. + +**Причина:** вызов корутины без `await` создаёт объект корутины, но не +запускает её. + +**Лечение:** `await корутина()` — всегда. + +## 7. Кириллица «кракозябрами» в консоли Windows + +```bat +chcp 65001 +``` +или запуск через `python -X utf8 файл.py`. В VS Code терминал обычно уже UTF-8. + +## 8. `pytest` не находит модуль студента + +**Причина:** тесты запускаются не из корня курса, или solution.py лежит не там. + +**Лечение:** запускать `python -m pytest tests/ -k labXX` строго из корня +курса; `solution.py` — в папке своей лабораторной. + +## 9. Замеры «прыгают» + +- закройте браузер/IDE-мессенджеры на время замеров; +- на ноутбуке подключите питание (троттлинг на батарее режет частоту вдвое); +- берите лучшее время из ≥3 повторов (`time_call`); +- не замеряйте в отладчике. + +## 10. Списывание + +Код проверяется и на совпадение между собой, и на понимание при защите. +Варианты у всех разные (см. `common/варианты.md`) — чужой вариант виден сразу. \ No newline at end of file diff --git a/common/чек-лист_сдачи.md b/common/чек-лист_сдачи.md new file mode 100644 index 0000000..e489f7a --- /dev/null +++ b/common/чек-лист_сдачи.md @@ -0,0 +1,36 @@ +# Чек-лист сдачи лабораторной + +Перед сдачей пройдитесь по списку — это снимает 90% возвратов на доработку. + +## Код + +- [ ] `solution.py` заполнен, интерфейсы (имена функций, сигнатуры) не менялись — + по ним работают автотесты +- [ ] В файлах, где создаются процессы (`multiprocessing`), есть + `if __name__ == "__main__":` — иначе на Windows будет `BrokenProcessPool` +- [ ] Нет абсолютных путей и путей с диском (`C:\...`) — только относительные +- [ ] Код запускается из корня курса: `python labXX_.../solution.py` +- [ ] Нет `print`-мусора; вывод осмысленный (таблицы, результаты, checksum) +- [ ] Нет «магического» ускорения за счёт уменьшения объёма работы: + checksum параллельного запуска совпадает с последовательным + +## Замеры + +- [ ] Замерены T(p) для всех p из вашего варианта (например 1, 2, 4) +- [ ] Каждый замер — минимум 3 повтора, берётся лучшее время +- [ ] Замеры сделаны на одной машине, без фоновых программ +- [ ] Последовательный запуск тоже замерен (это T(1), база для сравнения) + +## Отчёт + +- [ ] Заполнен по `common/шаблон_отчёта.md` +- [ ] Таблица замеров: T(p), S(p), E(p) +- [ ] График ускорения S(p) (картинка вставлена в отчёт) +- [ ] Анализ: почему ускорение такое, где накладные расходы, что ограничивает +- [ ] Ответы на контрольные вопросы из методички +- [ ] Указаны ОС, CPU и параметры варианта + +## Самопроверка + +- [ ] `python -m pytest tests/ -k labXX` — все тесты зелёные +- [ ] Код отформатирован, нет закомментированных кусков и TODO-заглушек \ No newline at end of file diff --git a/common/шаблон_отчёта.md b/common/шаблон_отчёта.md new file mode 100644 index 0000000..b6431f1 --- /dev/null +++ b/common/шаблон_отчёта.md @@ -0,0 +1,70 @@ +# Шаблон отчёта по лабораторной работе + +> Скопируйте этот файл в папку своей лабораторной (например, +> `lab02_threads/отчёт.md`), заполните и приложите графики. + +- **ФИО:** ____________ +- **Группа:** ____________ +- **Вариант:** № ____ из `common/варианты.md` +- **Задача:** ____ (описание + параметры варианта) +- **ОС / CPU / объём RAM:** ____ (например: Windows 11, AMD Ryzen 5 5600, 16 ГБ) + +## 1. Цель работы + +(2–3 предложения своими словами) + +## 2. Краткая теория + +(5–10 предложений: какие механизмы использовались и почему именно они +подходят/не подходят для вашей задачи. Переписывать методичку не нужно — +сформулируйте понимание своими словами.) + +## 3. Описание решения + +- что разбивается на части и почему именно так; +- что выполняет каждый воркер; +- как собирается итог; +- какие средства синхронизации использованы и зачем (если использовались). + +## 4. Результаты замеров + +Таблица: время T(p) для p = 1, 2, 4 (…), ускорение S(p) = T(1)/T(p), +эффективность E(p) = S(p)/p. + +| Подход | T, c | S(p) | E(p) | +|---|---|---|---| +| последовательно | | 1.00 | 1.00 | +| потоки, p=2 | | | | +| потоки, p=4 | | | | + +Как замерять: `common/benchmark.py` (`time_call`, минимум 3 повтора, +берётся лучшее время). Замеры — на спокойной машине, без фоновых программ. + +## 5. Графики + +- график ускорения S(p) — `save_speedup_plot` из `common/benchmark.py`; +- при необходимости — столбчатая диаграмма времени. + +Вставьте сюда картинки: + +![ускорение](speedup.png) + +## 6. Анализ + +Ответьте письменно (по 2��5 предложений на пункт): + +1. Получено ли ускорение? На сколько? Совпадает ли с ожиданиями? +2. Почему именно такая модель (потоки/процессы/async) подходит или не + подходит вашей задаче? +3. Наблюдались ли накладные расходы (запуск процессов, передача данных, + синхронизация)? Где они видны в замерах? +4. Что ограничивает дальнейший рост ускорения (GIL, память, число ядер, + доля последовательного кода)? + +## 7. Выводы + +(3–6 предложений: что изучено, какие практические выводы сделаны.) + +## 8. Ответы на контрольные вопросы + +(Из конца методички. Полными предложениями, своими словами.) \ No newline at end of file diff --git a/lab00_intro/check_env.py b/lab00_intro/check_env.py new file mode 100644 index 0000000..defd77c --- /dev/null +++ b/lab00_intro/check_env.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""Лабораторная 0: проверка готовности окружения. + +Запуск из корня курса: + python lab00_intro/check_env.py + +Скрипт проверяет версию Python, библиотеки, число ядер и работоспособность +threading / multiprocessing / asyncio. Все проверки должны напечатать [OK]. +""" +import os +import sys + +# делаем доступным импорт common.* при запуске из корня курса +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +FAILED = [] + + +def check(name: str, fn): + try: + detail = fn() + print(f"[OK] {name}{(': ' + str(detail)) if detail else ''}") + except Exception as e: # noqa: BLE001 + FAILED.append(name) + print(f"[FAIL] {name}: {type(e).__name__}: {e}") + + +def check_python_version(): + v = sys.version_info + assert v >= (3, 10), f"нужен Python 3.10+, у вас {v.major}.{v.minor}" + return f"Python {v.major}.{v.minor}.{v.micro}" + + +def check_modules(): + import numpy + import matplotlib + import pytest + return (f"numpy {numpy.__version__}, matplotlib {matplotlib.__version__}, " + f"pytest {pytest.__version__}") + + +def check_cpu_count(): + import multiprocessing + n = multiprocessing.cpu_count() + assert n >= 2, "нужно минимум 2 логических ядра" + return f"{n} логических ядер" + + +def check_threading(): + from concurrent.futures import ThreadPoolExecutor + + results = [] + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda x: x * x, range(8))) + assert results == [i * i for i in range(8)] + return "ThreadPoolExecutor работает" + + +def _double(x): + """Функция модульного уровня — Pool требует пиккелируемость.""" + return x * 2 + + +def check_multiprocessing(): + import multiprocessing + + ctx = multiprocessing.get_context() # spawn на Windows, fork на macOS/Linux + with ctx.Pool(2) as pool: + res = pool.map(_double, [1, 2, 3, 4]) + assert res == [2, 4, 6, 8], res + return f"метод запуска: {ctx.get_start_method()}" + + +def check_asyncio(): + import asyncio + + async def main(): + await asyncio.sleep(0.01) + return 42 + + assert asyncio.run(main()) == 42 + return "event loop работает" + + +def check_common_tasks(): + from common.tasks import get_variant + + v = get_variant(1) + assert "task_name" in v and "io" in v + return f"вариант 1: {v['describe']}" + + +def main(): + print("=" * 60) + print("Проверка окружения для курса «Технология параллельного программирования»") + print("=" * 60) + check("Версия Python", check_python_version) + check("Библиотеки", check_modules) + check("Число ядер CPU", check_cpu_count) + check("Потоки (threading)", check_threading) + check("Процессы (multiprocessing)", check_multiprocessing) + check("Асинхронность (asyncio)", check_asyncio) + check("Банк задач (common.tasks)", check_common_tasks) + print("-" * 60) + if FAILED: + print(f"ПРОБЛЕМЫ: {', '.join(FAILED)}") + print("Разберитесь по методичке lab00_intro/методичка.md — без этого") + print("следующие лабораторные не будут работать.") + sys.exit(1) + print("Всё готово. Запомните свой вариант из common/варианты.md.") + if os.name == "nt": + print("(Кириллица отображается кракозябрами? Выполните: chcp 65001)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab00_intro/методичка.md b/lab00_intro/методичка.md new file mode 100644 index 0000000..840ff3a --- /dev/null +++ b/lab00_intro/методичка.md @@ -0,0 +1,111 @@ +# Лабораторная работа 0. Подготовка окружения + +**Время на работу:** 1 пара. **Оценка:** зачёт/не зачёт. + +## Цель работы + +Подготовить рабочее окружение и убедиться, что все механизмы параллельного +программирования доступны на вашей машине — до того, как вы начнёте основную +лабораторную. + +## Теория (минимум) + +### Виртуальное окружение (venv) + +Виртуальное окружение — изолированная папка с собственной копией Python и +своим набором библиотек. Оно нужно, чтобы зависимости курса не конфликтовали +с другими проектами. Активированное окружение видно в терминале по префиксу +`(.venv)`. + +### Почему не Google Colab / онлайн-интерпретаторы + +Параллельность — это про реальные ядра CPU и реальную ОС. В облаке вы не +получите ни честного числа ядер, ни контроля над фоновыми процессами, ни +Windows-специфики `spawn`. Работаем локально. + +### Модель памяти Python: всё передаётся по ссылке + +В Python переменные — ссылки на объекты. Для потоков это значит: **все потоки +одного процесса видят одни и те же объекты** — отсюда гонки данных (лаба 5). +Для процессов это значит: **каждый процесс получает свою копию данных** +(через pickle) — отсюда накладные расходы (лаба 4). + +## Задание + +### Шаг 1. Установите Python 3.10+ + +Windows: python.org → Downloads → «Windows installer (64-bit)». +**Обязательно** отметьте «Add python.exe to PATH» на первом экране установщика. + +Проверка (новое окно терминала): + +```bat +python --version +``` + +### Шаг 2. VS Code + +Установите VS Code и расширение **Python** (издатель Microsoft). Откройте +папку курса: File → Open Folder. Терминал: Terminal → New Terminal +(или `` Ctrl+` ``). + +### Шаг 3. Виртуальное окружение и зависимости + +В терминале VS Code **из корня курса**: + +```bat +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +``` + +macOS/Linux: `source .venv/bin/activate`. + +Если VS Code спросит интерпретатор — выберите `.\.venv\Scripts\python.exe`. + +### Шаг 4. Git (кратко, если ещё не знакомы) + +```bat +git init +git add . +git commit -m "lab 0: окружение готово" +``` + +Дальше фиксируйте прогресс коммитами — это история вашей работы (и аргумент +в спорах о списывании). + +### Шаг 5. Проверка окружения + +```bat +python lab00_intro\check_env.py +``` + +Скрипт проверит версию Python, наличие библиотек, число ядер CPU и работоспособность +потоков, процессов и asyncio. Все проверки должны быть `[OK]`. + +### Шаг 6. Свой вариант + +```python +from common.tasks import get_variant +print(get_variant(<ваш номер в журнале>)) +``` + +Запишите параметры — они понадобятся во всех следующих лабораторных. + +## Контрольные вопросы + +1. Зачем нужно виртуальное окружение? Что произойдёт, если его не создавать? +2. Чем процесс отличается от потока? (Подсказка: общая память.) +3. Почему для этого курса важна работа на локальной машине, а не в облаке? + +## Что сдаётся + +- Скриншот вывода `check_env.py` (все проверки `[OK]`); +- короткий ответ на контрольные вопросы (можно в чате при сдаче). + +## Критерии зачёта + +| Пункт | Баллы | +|---|---| +| Окружение развёрнуто, check_env.py всё `[OK]` | 1 | +| Ответы на контрольные вопросы | 1 | diff --git a/lab01_sequential/.DS_Store b/lab01_sequential/.DS_Store new file mode 100644 index 0000000..22dc2bc Binary files /dev/null and b/lab01_sequential/.DS_Store differ diff --git a/lab01_sequential/examples/01_perf_counter_demo.py b/lab01_sequential/examples/01_perf_counter_demo.py new file mode 100644 index 0000000..6441373 --- /dev/null +++ b/lab01_sequential/examples/01_perf_counter_demo.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""Демо: perf_counter против time.time и process_time. + +Запуск из корня курса: + python lab01_sequential/examples/01_perf_counter_demo.py + +Показывает: +- почему time.time() для замеров плох (не монотонные часы); +- как perf_counter и process_time диагностируют I/O-bound задачу. +""" +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + + +def demo_time_vs_perf(): + print("--- time.time() против time.perf_counter() ---") + t1 = time.time() + p1 = time.perf_counter() + time.sleep(0.05) + t2 = time.time() + p2 = time.perf_counter() + print(f"time.time(): {(t2 - t1) * 1000:8.2f} мс (точность зависит от ОС)") + print(f"time.perf_counter():{(p2 - p1) * 1000:8.2f} мс (монотонные, наносекундное разрешение)") + + +def demo_cpu_vs_io(): + print("\n--- CPU-bound против I/O-bound: perf_counter vs process_time ---") + + # CPU-bound: считаем + t0 = time.perf_counter() + c0 = time.process_time() + s = 0.0 + for i in range(3_000_000): + s += i ** 0.5 + cpu_wall = time.perf_counter() - t0 + cpu_proc = time.process_time() - c0 + + # I/O-bound: ждём (имитация сетевых запросов) + t0 = time.perf_counter() + c0 = time.process_time() + for _ in range(10): + time.sleep(0.05) # «ожидание ответа сервера» + io_wall = time.perf_counter() - t0 + io_proc = time.process_time() - c0 + + print(f"{'':14}{'настенное, c':>14}{'процессорное, c':>18}{'доля сна':>10}") + print(f"{'CPU-bound':14}{cpu_wall:>14.3f}{cpu_proc:>18.3f}" + f"{100 * (1 - cpu_proc / cpu_wall):>9.0f}%") + print(f"{'I/O-bound':14}{io_wall:>14.3f}{io_proc:>18.3f}" + f"{100 * (1 - io_proc / io_wall):>9.0f}%") + print("\nВывод: у I/O-задачи процессор почти всё время спит —") + print("именно поэтому потоки/asyncio могут её ускорить (лабы 2 и 6).") + + +if __name__ == "__main__": + demo_time_vs_perf() + demo_cpu_vs_io() \ No newline at end of file diff --git a/lab01_sequential/examples/02_split_check_demo.py b/lab01_sequential/examples/02_split_check_demo.py new file mode 100644 index 0000000..bb3db59 --- /dev/null +++ b/lab01_sequential/examples/02_split_check_demo.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""Демо: разбиение работы на куски не должно менять результат. + +Запуск из корня курса: + python lab01_sequential/examples/02_split_check_demo.py + +Ключевая идея курса: задача формулируется как split -> kernel -> combine -> +checksum. Разбиение — это подготовка к параллелизму: последовательный запуск +кусков обязан давать тот же checksum, что и один большой кусок. +""" +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from common.tasks import TASKS, smoke_params + + +def run(task_name: str, parts: int): + """Последовательный запуск задачи из `parts` кусков.""" + task = TASKS[task_name] + params = smoke_params(task_name) + data = task["build"](params) + chunks = task["split"](data, parts) + partials = [task["kernel"](ch) for ch in chunks] + raw = task["combine"](partials) + return task["checksum"](raw) + + +def main(): + print(f"{'задача':<12} {'parts=1':>18} {'parts=2':>18} {'parts=4':>18} совпадает") + for name in ("pi_mc", "integrate", "nqueens", "blur", "hashing"): + v1 = run(name, 1) + v2 = run(name, 2) + v4 = run(name, 4) + # float-результаты (π, интеграл) сравниваем с допуском: при разбиении + # меняется порядок суммирования, меняются последние биты мантиссы + def same(a, b): + if isinstance(a, float): + return abs(a - b) <= 1e-6 * max(1.0, abs(a)) + return a == b + same12 = same(v1, v2) + same14 = same(v1, v4) + marker = "True" if (same12 and same14) else "False <-- ОШИБКА" + print(f"{name:<12} {v1:>18.6f} {v2:>18.6f} {v4:>18.6f} {marker}") + print("\nЕсли checksum зависит от разбиения — параллельная версия будет") + print("давать «случайный» результат. Это ошибка, а не особенность.") + print("(Float-задачи сравниваются с допуском — см. код демо.)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab01_sequential/examples/03_cprofile_demo.py b/lab01_sequential/examples/03_cprofile_demo.py new file mode 100644 index 0000000..47979e2 --- /dev/null +++ b/lab01_sequential/examples/03_cprofile_demo.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""Демо: cProfile — где программа проводит время. + +Запуск из корня курса: + python lab01_sequential/examples/03_cprofile_demo.py + +На глаз две реализации выглядят одинаково. Профилировщик показывает, +что наивная transpose-версия медленнее — из-за плохой локальности памяти. +""" +import cProfile +import os +import pstats +import sys +import io + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +N = 250 + + +def matmul_rows(A, B): + """Доступ к B по строкам: b[k] читается целиком (хорошо для кэша).""" + C = [] + for i in range(N): + Ai, row = A[i], [] + for j in range(N): + s = 0.0 + for k in range(N): + s += Ai[k] * B[k][j] # B[k] — колонка, каждый раз новая строка + row.append(s) + C.append(row) + return C + + +def main(): + rng_a = [[float(i + j) for j in range(N)] for i in range(N)] + rng_b = [[float(i - j) for j in range(N)] for i in range(N)] + + profiler = cProfile.Profile() + profiler.enable() + C = matmul_rows(rng_a, rng_b) + profiler.disable() + + stream = io.StringIO() + stats = pstats.Stats(profiler, stream=stream) + stats.sort_stats("cumulative").print_stats(8) + print(stream.getvalue()) + print(f"checksum = {sum(sum(r) for r in C):.3f}") + print("Смотрим tototime: почти всё время — во внутреннем цикле matmul_rows.") + print("Это и есть узкое место, которое будут распараллеливать лабы 3–4.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab01_sequential/solution.py b/lab01_sequential/solution.py new file mode 100644 index 0000000..965e056 --- /dev/null +++ b/lab01_sequential/solution.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 1: последовательное вычисление и профилирование. + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab01.py). + +Запуск из корня курса: + python lab01_sequential/solution.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.benchmark import make_table, time_call +from common.tasks import TASKS, get_variant, io_fetch + +# TODO: впишите свой номер в журнале (1..20) +VARIANT_NUMBER = 0 + + +def run_cpu_sequential(task_name: str, params: dict, parts: int = 4): + """Последовательное выполнение задачи из `parts` кусков. + + Возвращает (checksum, elapsed_seconds). + + Схема: + build -> split -> kernel (по очереди!) -> combine -> checksum + """ + task = TASKS[task_name] + # TODO: реализуйте по схеме выше и замерьте время perf_counter-ом + raise NotImplementedError + + +def run_io_sequential(items: int, delay: float = 0.05): + """Последовательное выполнение I/O-нагрузки. + + Для каждого item из range(items) вызывает io_fetch(item, delay). + Возвращает (список_результатов, elapsed_seconds). + Результаты — в порядке возрастания item. + """ + # TODO: цикл по items, сбор результатов, замер времени + raise NotImplementedError + + +def main(): + v = get_variant(VARIANT_NUMBER) + print(f"Вариант {v['variant']}: {v['describe']}") + print(f"I/O-нагрузка: {v['io']['items']} запросов по {v['io']['delay']} c\n") + + # --- CPU-часть: T(1), T(2), T(4) -------------------------------------- + rows, checksums = [], [] + for parts in (1, 2, 4): + t, (cs, _) = time_call(run_cpu_sequential, + (v["task_name"], v["cpu_params"], parts)) + rows.append((f"CPU, {parts} кусков", t)) + checksums.append(cs) + print(make_table(rows, title="CPU-часть (последовательно)")) + + def same(a, b): + # float-результаты сравниваем с допуском (порядок суммирования!) + if isinstance(a, float) or isinstance(b, float): + return abs(a - b) <= 1e-6 * max(1.0, abs(a)) + return a == b + + ok = all(same(checksums[0], c) for c in checksums[1:]) + print(f"checksum совпадает при разном разбиении: {ok}\n") + + # --- I/O-часть ---------------------------------------------------------- + results, t_io = run_io_sequential(v["io"]["items"], v["io"]["delay"]) + t0 = time.perf_counter() + c0 = time.process_time() + run_io_sequential(v["io"]["items"], v["io"]["delay"]) + wall = time.perf_counter() - t0 + proc = time.process_time() - c0 + print(f"I/O-часть: {len(results)} запросов за {t_io:.3f} c") + print(f" настенное время (perf_counter): {wall:.3f} c") + print(f" процессорное время (process_time): {proc:.3f} c") + print(f" доля ожидания: {100 * (1 - proc / wall):.0f}%") + print(f" потенциал ускорения потоками (лаба 2): x{t_io / v['io']['delay']:.1f}\n") + + # --- Профилирование ------------------------------------------------------ + import cProfile + import io as _io + import pstats + + print("--- cProfile CPU-части (топ-5 по времени) ---") + profiler = cProfile.Profile() + profiler.enable() + run_cpu_sequential(v["task_name"], v["cpu_params"], parts=4) + profiler.disable() + stream = _io.StringIO() + pstats.Stats(profiler, stream=stream).sort_stats("cumulative").print_stats(5) + print(stream.getvalue()) + # TODO: выпишите в отчёт, какая функция — узкое место + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab01_sequential/методичка.md b/lab01_sequential/методичка.md new file mode 100644 index 0000000..de04bd0 --- /dev/null +++ b/lab01_sequential/методичка.md @@ -0,0 +1,115 @@ +# Лабораторная работа 1. Последовательное вычисление и профилирование + +**Время на работу:** 2 пары. + +## Цель работы + +Создать базовую линию: последовательную реализацию своей задачи и её замеры. +Без неё невозможно показать ускорение в следующих лабораторных — нечего будет +ускорять. + +## Теория + +### Последовательное вычисление + +Программа выполняет операцию за операцией, в одном потоке управления. Это +простейшая и самая предсказуемая модель: результат не зависит от порядка +выполнения, время выполнения = сумма времён всех операций. Все дальнейшие +лабораторные сравниваются с этой базой. + +### Два типа нагрузки + +**CPU-bound** — программа ограничена скоростью процессора: считает, шифрует, +сортирует. Время определяется числом элементарных операций. + +**I/O-bound** — программа ограничена ожиданием: сеть, диск, база данных. +Время определяется временем ожидания ответов, процессор при этом простаивает. + +Отличить просто: если удалить «полезную работу», а программа всё равно +медленная — это I/O-bound; если быстрая — CPU-bound. Тип нагрузки определяет, +какая модель параллелизма поможет (потоки — для I/O, процессы — для CPU, +async — для I/O в больших количествах). + +### Время: настенное и процессорное + +- `time.perf_counter()` — «настенные» часы: сколько времени прошло реально + (включая сон и ожидание). Ими меряют всё в этом курсе. +- `time.process_time()` — только время, потраченное процессором. + Разница между ними для I/O-задачи огромна — это её диагноз. + +Обе функции монотонные (не откатываются назад, как `time.time()` при +переводе часов), имеют наносекундное разрешение — всегда используйте их, а не +`time.time()`. + +### Профилирование + +Профилировщик отвечает на вопрос «где программа проводит время». `cProfile` +собирает статистику по функциям: число вызовов, суммарное время, время без +вложенных вызовов. + +```python +import cProfile +cProfile.run("main()", sort="cumulative") +``` + +Интерпретация: колонка `cumtime` — общее время функции (с вызовами), +`totime` — собственное. Первые строки обычно сразу показывают узкое место. + +## Задание + +Ваша задача и параметры — по варианту (`common/варианты.md`), доступ: + +```python +from common.tasks import get_variant, TASKS +v = get_variant(13) # ваш номер +task = TASKS[v["task_name"]] +data = task["build"](v["cpu_params"]) +``` + +Заполните `solution.py` (интерфейсы менять нельзя): + +1. **`run_cpu_sequential(task_name, params, parts=4)`** — выполняет задачу + последовательно: `build` → разбиение на `parts` кусков → `kernel` для + каждого куска в цикле → `combine` → вернуть `checksum`. Куски + обязательны: в следующих лабораторных те же куски уйдут воркерам. +2. **`run_io_sequential(items, delay)`** — для каждого элемента вызывает + `common.tasks.io_fetch(item, delay)` и собирает результаты в список + (в порядке возрастания item). +3. **`main()`** — запускает обе части на параметрах своего варианта, выводит + таблицу времен (`common.benchmark.make_table`) и значения checksum. + +### Измерения для отчёта + +- CPU-часть: T(1) при разбиении на 1, 2, 4 куска (убедитесь, что checksum + одинаков и время почти не меняется — разбиение не должно менять работу). +- I/O-часть: суммарное время для items из варианта; сравните + `perf_counter` и `process_time` — увидите, сколько времени машина спит. +- Профилирование CPU-части: `cProfile.run(...)`, топ-5 функций по времени. + Укажите в отчёте, какая функция — узкое место. + +## Контрольные вопросы + +1. Чем CPU-bound задача отличается от I/O-bound? Какая у вас? +2. Почему для замеров нужен `perf_counter`, а не `time.time()`? +3. Что показывает `cumtime` и `tototime` в выводе cProfile? +4. Почему checksum не должен зависеть от числа кусков? Что это проверяет? + +## Что сдаётся + +`solution.py` + отчёт по шаблону: таблицы T(1/2/4 кусков), время I/O-части, +сравнение perf_counter/process_time, топ-5 из cProfile, анализ. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| `run_cpu_sequential` работает, checksum совпадает при разном разбиении | 2 | +| `run_io_sequential` работает | 1 | +| Замеры и сравнение perf_counter/process_time | 2 | +| Профилирование и вывод об узком месте | 2 | +| Отчёт: таблицы, анализ, ответы на вопросы | 3 | + +## Типичные ошибки + +См. `common/типичные_ошибки.md` (пп. 8, 9). Здесь их всего две: замеры в +отладчике и замеры без повторов. diff --git a/lab02_threads/examples/01_threads_sleep_demo.py b/lab02_threads/examples/01_threads_sleep_demo.py new file mode 100644 index 0000000..a86454e --- /dev/null +++ b/lab02_threads/examples/01_threads_sleep_demo.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""Демо: потоки выполняются конкурентно — sleep не складывается. + +Запуск из корня курса: + python lab02_threads/examples/01_threads_sleep_demo.py + +Пять потоков спят по 0.4 c. Сумма времён = 2 c, реальное время ≈ 0.4 c: +пока один поток спит, процессор обслуживает другие. +""" +import threading +import time + +N_THREADS = 5 +SLEEP = 0.4 + + +def worker(idx: int) -> None: + name = threading.current_thread().name + t0 = time.perf_counter() + time.sleep(SLEEP) # «ожидание сети/диска» + dt = time.perf_counter() - t0 + print(f" {name}: {dt:.3f} c") + + +def main(): + print(f"Запускаем {N_THREADS} потоков, каждый спит {SLEEP} c") + + threads = [threading.Thread(target=worker, args=(i,), name=f"поток-{i}") + for i in range(N_THREADS)] + + t0 = time.perf_counter() + for t in threads: + t.start() # ВСЕ стартуют первыми... + for t in threads: + t.join() # ...потом ВСЕ ждём (start и join не перемешивать!) + total = time.perf_counter() - t0 + + print(f"Суммарно: {total:.3f} c " + f"(сумма времён была бы {N_THREADS * SLEEP:.1f} c)") + print(f"Ускорение: x{N_THREADS * SLEEP / total:.1f} — так работает I/O-параллелизм") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab02_threads/examples/02_executor_future_demo.py b/lab02_threads/examples/02_executor_future_demo.py new file mode 100644 index 0000000..a24b1b4 --- /dev/null +++ b/lab02_threads/examples/02_executor_future_demo.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""Демо: ThreadPoolExecutor, Future и порядок завершения. + +Запуск из корня курса: + python lab02_threads/examples/02_executor_future_demo.py + +Показывает: +- pool.map: результаты в порядке входа, даже если завершились в другом; +- submit + as_completed: обрабатываем по мере готовности. +""" +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from common.tasks import io_fetch + + +def main(): + # «Сервер» отвечает тем медленнее, чем больше номер запроса + delays = [0.30, 0.25, 0.20, 0.15, 0.10] + + print("--- pool.map: результаты в порядке входа ---") + t0 = time.perf_counter() + with ThreadPoolExecutor(max_workers=5) as pool: + results = list(pool.map(io_fetch, range(5), delays)) + print(f" порядок: {[r[0] for r in results]} (как на входе)") + print(f" время: {time.perf_counter() - t0:.3f} c " + f"(последовательный был бы {sum(delays):.2f} c)\n") + + print("--- submit + as_completed: по мере готовности ---") + t0 = time.perf_counter() + with ThreadPoolExecutor(max_workers=5) as pool: + futures = {pool.submit(io_fetch, i, d): i for i, d in enumerate(delays)} + for fut in as_completed(futures): + item = futures[fut] + print(f" завершён запрос {item} " + f"(через {time.perf_counter() - t0:.3f} c)") + print(f" время: {time.perf_counter() - t0:.3f} c\n") + + print("--- Future: .result() блокирует до готовности ---") + with ThreadPoolExecutor(max_workers=2) as pool: + fut = pool.submit(io_fetch, 42, 0.20) + print(f" done() сразу после submit: {fut.done()}") + result = fut.result() # ждём + print(f" done() после result(): {fut.done()}, результат: {result}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab02_threads/examples/03_threads_cpu_demo.py b/lab02_threads/examples/03_threads_cpu_demo.py new file mode 100644 index 0000000..524953e --- /dev/null +++ b/lab02_threads/examples/03_threads_cpu_demo.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Демо: предвестник лабы 3 — потоки НЕ ускоряют счёт. + +Запуск из корня курса: + python lab02_threads/examples/03_threads_cpu_demo.py + +Та же схема, что с sleep, но вместо sleep — счёт. Время не уменьшается: +GIL разрешает исполнять байт-код Python только одному потоку. +Подробное исследование — лабораторная 3. +""" +import os +import sys +import threading +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + + +def cpu_worker(n: int) -> None: + s = 0.0 + for i in range(n): + s += i ** 0.5 # чистый Python-код: держит GIL + return s + + +def run_threads(n_threads: int, n_per_thread: int) -> float: + threads = [threading.Thread(target=cpu_worker, args=(n_per_thread,)) + for _ in range(n_threads)] + t0 = time.perf_counter() + for t in threads: + t.start() + for t in threads: + t.join() + return time.perf_counter() - t0 + + +def main(): + TOTAL = 3_000_000 + + t0 = time.perf_counter() + cpu_worker(TOTAL) + t_seq = time.perf_counter() - t0 + print(f"1 поток, {TOTAL:,} итераций: {t_seq:.3f} c") + + t2 = run_threads(2, TOTAL // 2) + print(f"2 потока, по {TOTAL // 2:,}: {t2:.3f} c " + f"(ускорение x{t_seq / t2:.2f})") + + t4 = run_threads(4, TOTAL // 4) + print(f"4 потока, по {TOTAL // 4:,}: {t4:.3f} c " + f"(ускорение x{t_seq / t4:.2f})") + + print("\nУскорения нет (даже хуже!) — GIL. Но тот же код с time.sleep") + print("ускорился бы почти идеально (демо 01). Разницу изучаем в лабе 3.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab02_threads/solution.py b/lab02_threads/solution.py new file mode 100644 index 0000000..8ca8bf2 --- /dev/null +++ b/lab02_threads/solution.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 2: многопоточный код (I/O-bound). + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab02.py). + +Запуск из корня курса: + python lab02_threads/solution.py +""" +import os +import sys +import threading +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.benchmark import make_table, save_speedup_plot, time_call +from common.tasks import get_variant, io_fetch + +# TODO: впишите свой номер в журнале (1..20) +VARIANT_NUMBER = 0 + + +def _split_range(items: int, parts: int): + """Разбиение range(items) на parts почти равных кусков (списки item'ов).""" + # TODO: верните список из parts списков номеров запросов + raise NotImplementedError + + +def run_io_threads_manual(items: int, delay: float = 0.05, p: int = 4): + """I/O-нагрузка на p ручных потоках. + + Каждый поток обрабатывает СВОЙ кусок и кладёт результаты в СВОЙ список. + Вернуть (список_результатов, elapsed_seconds), упорядочено по item. + + Подсказки: + - threading.Thread(target=worker, args=(...)); + - start() всех потоков ДО первого join() — иначе выйдет последовательно. + """ + raise NotImplementedError + + +def run_io_threads_pool(items: int, delay: float = 0.05, p: int = 4): + """I/O-нагрузка через ThreadPoolExecutor(max_workers=p) и pool.map. + + Вернуть (список_результатов, elapsed_seconds), упорядочено по item. + pool.map сохраняет порядок входа независимо от порядка завершения. + """ + raise NotImplementedError + + +def main(): + v = get_variant(VARIANT_NUMBER) + items, delay = v["io"]["items"], v["io"]["delay"] + print(f"Вариант {v['variant']}: I/O-нагрузка {items} запросов по {delay} c\n") + + rows = {"ручные потоки": [], "пул потоков": []} + for p in v["p_list"]: + t1, (res, _) = time_call(run_io_threads_manual, (items, delay, p), repeats=1) + assert [r[0] for r in res] == list(range(items)), "порядок нарушен!" + rows["ручные потоки"].append((p, t1)) + + t2, (res, _) = time_call(run_io_threads_pool, (items, delay, p), repeats=1) + assert [r[0] for r in res] == list(range(items)), "порядок нарушен!" + rows["пул потоков"].append((p, t2)) + + ideal = items * delay / p + print(f"p={p}: вручную {t1:.3f} c | пул {t2:.3f} c | идеал ≈ {ideal:.3f} c") + + print() + table_rows = [(f"пул, p={p}", t) for p, t in rows["пул потоков"]] + print(make_table(table_rows, title="Пул потоков")) + + save_speedup_plot( + rows, + f"Ускорение I/O-нагрузки потоками ({items} запросов)", + os.path.join(os.path.dirname(__file__), "speedup.png")) + + # TODO: в отчёт — почему ускорение близко к идеальному? + # TODO (по желанию, +1 балл): submit + as_completed, порядок завершения + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab02_threads/методичка.md b/lab02_threads/методичка.md new file mode 100644 index 0000000..9ae5fea --- /dev/null +++ b/lab02_threads/методичка.md @@ -0,0 +1,120 @@ +# Лабораторная работа 2. Многопоточный код (I/O-bound) + +**Время на работу:** 2 пары. **Зависимости:** лаба 1. + +## Цель работы + +Освоить потоки на задаче, где они реально дают ускорение — I/O-bound. +К концу работы вы должны уметь объяснить, почему один и тот же механизм +бесполезен для CPU-задач (лаба 3). + +## Теория + +### Что такое поток + +Поток (thread) — независимая последовательность инструкций **внутри одного +процесса**. Все потоки делят общую память: один и тот же список, словарь, +глобальную переменную видно из любого потока. Это делает потоки лёгкими +(создание ~десятки микросекунд, у процессов — миллисекунды) и опасными +одновременно: два потока могут менять одну переменную без ведома друг друга +(лаба 5). + +### Почему потоки ускоряют I/O-bound, но не CPU-bound + +Когда поток выполняет `time.sleep`, чтение сокета или диска, он **отпускает +процессор** — ОС переключает его на другой поток. Десять потоков, ждущих сеть, +«спят» одновременно, и суммарное время ≈ времени одного запроса. + +Когда поток считает (`x = a * b`), он держит процессор, а в CPython — ещё и +**GIL** (Global Interpreter Lock): глобальный замок, разрешающий исполнять +байт-код Python только одному потоку процесса. Поэтому CPU-код потоками не +ускоряется в принципе (подробно — лаба 3). + +Правило: **потоки — для ожидания, процессы — для вычислений.** + +### threading vs concurrent.futures + +`threading.Thread` — ручное управление: создать, `start()`, `join()`, +самому разложить данные и собрать результаты. Полезно понять один раз. + +`concurrent.futures.ThreadPoolExecutor` — пул потоков и высокоуровневый +интерфейс: + +```python +with ThreadPoolExecutor(max_workers=p) as pool: + results = list(pool.map(io_fetch, items)) # порядок входа сохранён + futures = [pool.submit(io_fetch, i) for i in items] # или Future-объекты +``` + +`submit` возвращает `Future` — «обещание» результата: `.result()` блокирует +до готовности, `.done()` проверяет готовность, можно навесить колбэк +`add_done_callback`. `pool.map` — как встроенный `map`, но параллельный; +результаты приходят в порядке входа. + +В работе используются оба способа: `Thread` — чтобы понять механику, +`ThreadPoolExecutor` — как рабочий инструмент. + +## Задание + +I/O-нагрузка та же, что в лабе 1 (`io_fetch` из `common.tasks`: имитация +запроса задержкой 0.05 c). Параметры items — из вашего варианта. + +Заполните `solution.py`: + +1. **`run_io_threads_manual(items, delay, p)`** — разбить `range(items)` на + `p` кусков, на каждый кусок — свой `threading.Thread`. Каждый поток + кладёт результаты в **свой** список (общая структура без синхронизации — + пока избегаем). Собрать куски по порядку. +2. **`run_io_threads_pool(items, delay, p)`** — то же через + `ThreadPoolExecutor(max_workers=p)` и `pool.map`. +3. **`main()`** — замерить T(1), T(2), T(4) (и T(8), если есть в варианте) + обоими способами; построить таблицу и график ускорения + (`common.benchmark`). + +### Ожидаемые результаты (проверьте себя) + +- T(1) ≈ items × delay (например 24 × 0.05 = 1.2 c); +- T(4) ≈ items × delay / 4 (если items кратно 4) — почти идеальное ускорение; +- ручные потоки и пул дают примерно одинаковое время. + +Если T(4) ≈ T(1) — вы где-то синхронизируете лишний раз или запускаете +потоки по очереди (проверьте: `start()` всех потоков должен быть **до** +первого `join()`). + +### Дополнительно (по желанию, +1 балл) + +- `ThreadPoolExecutor` + `submit`/`as_completed`: выводить номер завершившегося + запроса по мере готовности — увидите, что порядок завершения не совпадает + с порядком запуска. + +## Контрольные вопросы + +1. Почему `time.sleep` в 4 потоках занимает те же 0.05 c, а не 0.2 c? +2. Чем `Future` отличается от готового результата? Что делает `.result()`? +3. Почему потоки имеют общую память и чем это опасно? +4. Что произойдёт с вашей I/O-задачей, если `max_workers=100`? Почему рост + останавливается? + +## Что сдаётся + +`solution.py` + отчёт: таблица T(p) обоих способов, график S(p), анализ +(совпало ли с ожидаемым, где видны накладные расходы на создание потоков), +ответы на контрольные вопросы. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| Ручные потоки: результаты корректны и упорядочены | 2 | +| Пул потоков: результаты корректны | 2 | +| Замеры T(1/2/4), ускорение ≈ p на I/O-части | 2 | +| График и анализ в отчёте | 2 | +| Ответы на контрольные вопросы | 2 | + +## Типичные ошибки + +- `start()` и сразу `join()` в одном цикле — потоки выполнятся по очереди. +- Общий список без блокировки (`results.append(...)` из многих потоков) — + в этой лабе «повезёт» (append атомарен из-за GIL), но полагаться на это + нельзя — в лабе 5 это сломают демонстративно. +- Забыли `args=(chunk,)` в `Thread` — функция вызвалась в главном потоке. diff --git a/lab03_gil/examples/01_gil_basics_demo.py b/lab03_gil/examples/01_gil_basics_demo.py new file mode 100644 index 0000000..3220d6b --- /dev/null +++ b/lab03_gil/examples/01_gil_basics_demo.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""Демо: GIL изнутри — интервал переключения и stat. + +Запуск из корня курса: + python lab03_gil/examples/01_gil_basics_demo.py + +Показывает: +- sys.getswitchinterval() — как часто потоки уступают GIL; +- sys._is_gil_enabled() (Python 3.13+) — статус GIL; +- поток-«наблюдатель» работает, пока главный поток занят счётом + (переключения есть!), но суммарная производительность не растёт. +""" +import os +import sys +import threading +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + + +def burn(n): + s = 0.0 + for i in range(n): + s += i ** 0.5 + return s + + +def main(): + print(f"Python: {sys.version.split()[0]}") + if hasattr(sys, "_is_gil_enabled"): + print(f"GIL включён: {sys._is_gil_enabled()}") + print(f"интервал переключения GIL: {sys.getswitchinterval() * 1000:.0f} мс\n") + + N = 4_000_000 + + # 1. Один поток + t0 = time.perf_counter() + burn(N) + t1 = time.perf_counter() - t0 + print(f"1 поток: {t1:.3f} c") + + # 2. Поток-наблюдатель: он живёт (переключения идут), но работе это не помогает + ticks = [0] + + def observer(stop_flag): + while not stop_flag[0]: + ticks[0] += 1 + time.sleep(0.005) + + stop = [False] + obs = threading.Thread(target=observer, args=(stop,), daemon=True) + obs.start() + + t0 = time.perf_counter() + burn(N) # главный поток считает + t2 = time.perf_counter() - t0 + stop[0] = True + obs.join() + print(f"1 поток + наблюдатель: {t2:.3f} c (наблюдатель проснулся " + f"{ticks[0]} раз) — работа не замедлилась: переключения дешёвые") + + # 3. Два считающих потока: время то же, работы вдвое больше — но не быстрее + t0 = time.perf_counter() + threads = [threading.Thread(target=burn, args=(N // 2,)) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + t4 = time.perf_counter() - t0 + print(f"2 потока по {N // 2}: {t4:.3f} c => S={t1 / t4:.2f} (GIL!)") + print("\nВывод: переключения GIL происходят, но байт-код всё равно") + print("исполняется по очереди — суммарная скорость не растёт.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab03_gil/examples/02_hashlib_gil_demo.py b/lab03_gil/examples/02_hashlib_gil_demo.py new file mode 100644 index 0000000..68fcbe2 --- /dev/null +++ b/lab03_gil/examples/02_hashlib_gil_demo.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""Демо: hashlib освобождает GIL — потоки дают настоящее ускорение. + +Запуск из корня курса: + python lab03_gil/examples/02_hashlib_gil_demo.py + +hashlib.sha256 отпускает GIL на время хеширования ОДНОГО блока. На коротких +блоках (4 КБ) вызовы слишком быстрые — GIL забирается обратно, ускорения нет. +На больших блоках (1 МБ) вызовы длинные — потоки работают параллельно. +""" +import hashlib +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +# Три набора данных (для hashlib — одинаковый суммарный объём, разные блоки) +PY_DATA = [os.urandom(256 * 1024) for _ in range(16)] # 4 МБ, для чистого Python +HASH_BIG = [os.urandom(1 << 20) for _ in range(48)] # 48 МБ блоками по 1 МБ +HASH_SMALL = [os.urandom(4096) for _ in range(48 * 256)] # те же 48 МБ блоками по 4 КБ + +MASK = 0xFFFFFFFFFFFFFFFF + + +def py_checksum(chunk): + """Чистый Python: GIL не отпускает.""" + acc = 0 + for b in chunk: + for x in b: # питоновский цикл по байтам + acc = (acc + x * 31) & MASK + return acc + + +def hash_checksum(chunk): + """C-функция hashlib: GIL отпускается на время хеширования блока.""" + acc = 0 + h = hashlib.sha256 + for b in chunk: + acc = (acc + int.from_bytes(h(b).digest()[:8], "big")) & MASK + return acc + + +def run_threads(fn, data, parts): + bounds = [i * len(data) // parts for i in range(parts + 1)] + chunks = [data[bounds[i]:bounds[i + 1]] for i in range(parts)] + with ThreadPoolExecutor(max_workers=parts) as pool: + return list(pool.map(fn, chunks)) + + +def min_time(fn, data, parts, repeats=3): + best = float("inf") + for _ in range(repeats): + t0 = time.perf_counter() + run_threads(fn, data, parts) + best = min(best, time.perf_counter() - t0) + return best + + +def bench(label, fn, data): + acc1 = sum(run_threads(fn, data, 1)) & MASK + acc4 = sum(run_threads(fn, data, 4)) & MASK + assert acc1 == acc4, "результат зависит от разбиения — ошибка!" + t1 = min_time(fn, data, 1) + t4 = min_time(fn, data, 4) + print(f"{label:<26} T(1)={t1:.3f} c T(4)={t4:.3f} c S(4)={t1 / t4:.2f}") + + +def main(): + print("hashlib: одинаковый суммарный объём 48 МБ, разные размеры блоков\n") + bench("чистый Python", py_checksum, PY_DATA) + bench("hashlib, блоки 4 КБ", hash_checksum, HASH_SMALL) + bench("hashlib, блоки 1 МБ", hash_checksum, HASH_BIG) + print("\nS(4)≈1 у чистого Python и мелких блоков против S(4)≈ядрам у") + print("больших блоков. GIL отпускается на время ОДНОГО вызова C-функции —") + print("вызовы должны быть длинными.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab03_gil/examples/03_switch_interval_demo.py b/lab03_gil/examples/03_switch_interval_demo.py new file mode 100644 index 0000000..d9895ae --- /dev/null +++ b/lab03_gil/examples/03_switch_interval_demo.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""Демо: sys.setswitchinterval — интервал переключения GIL. + +Запуск из корня курса: + python lab03_gil/examples/03_switch_interval_demo.py + +Уменьшение интервала переключения не добавляет параллельности (GIL всё равно +один), но увеличивает накладные расходы: слишком частые переключения +замедляют работу. +""" +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + + +def burn(n): + s = 0.0 + for i in range(n): + s += i ** 0.5 + return s + + +def bench(interval, n_threads, n_total): + sys.setswitchinterval(interval) + bounds = [i * n_total // n_threads for i in range(n_threads + 1)] + chunks = [bounds[i + 1] - bounds[i] for i in range(n_threads)] + t0 = time.perf_counter() + with ThreadPoolExecutor(max_workers=n_threads) as pool: + pool.map(burn, chunks) + return time.perf_counter() - t0 + + +def main(): + N = 4_000_000 + print(f"{'интервал':>10} {'T(1 поток)':>12} {'T(4 потока)':>12}") + for interval in (0.005, 0.0005, 0.00005): + t1 = bench(interval, 1, N) + t4 = bench(interval, 4, N) + print(f"{interval * 1000:>9.1f}мс {t1:>11.3f}c {t4:>11.3f}c" + f" S(4)={t1 / t4:.2f}") + sys.setswitchinterval(0.005) # вернуть значение по умолчанию + print("\nВывод: интервал переключения влияет на накладные расходы,") + print("но параллельности не добавляет — держатель GIL всё равно один.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab03_gil/solution.py b/lab03_gil/solution.py new file mode 100644 index 0000000..3d0f02d --- /dev/null +++ b/lab03_gil/solution.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 3: GIL — почему потоки не ускоряют CPU-код. + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab03.py). + +Запуск из корня курса: + python lab03_gil/solution.py +""" +import hashlib +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.benchmark import save_speedup_plot, time_call +from common.tasks import TASKS, get_variant + +# TODO: впишите свой номер в журнале (1..20) +VARIANT_NUMBER = 0 + + +def run_task_on_threads(task_name: str, params: dict, p: int): + """Выполнить CPU-задачу на p потоках (ThreadPoolExecutor). + + Каждый поток получает свой кусок (task['split']) и выполняет task['kernel']. + Вернуть (checksum, elapsed_seconds). checksum обязан совпасть с + последовательным запуском — иначе разбиение/сборка неверны. + + Подсказка: потоки, в отличие от процессов, не требуют пиккелируемости — + task["kernel"] можно вызывать прямо в воркере. + """ + task = TASKS[task_name] + # TODO: build -> split -> pool.map(kernel, chunks) -> combine -> checksum + raise NotImplementedError + + +def measure_cpu_threads(task_name: str, params: dict, p_list): + """Замеры T(p) на потоках для чистого-Python задачи. + + Вернуть dict {p: лучшее_время}. p=1 — база. Проверять совпадение + checksum при каждом p! + """ + # TODO: для каждого p -> time_call(run_task_on_threads, ..., repeats=3) + raise NotImplementedError + + +def measure_gil_release(p_list, block_size=1 << 20, blocks_per_part=32): + """Замеры T(p) для hashlib (освобождает GIL на длинных вызовах). + + Готовит данные заранее: W = blocks_per_part * max(p_list) блоков по + block_size байт (os.urandom). Общий объём W делится между p потоками — + как в CPU-замерах. Вернуть dict {p: лучшее_время}, checksum проверять. + + ВАЖНО: блоки должны быть большими (≥ 256 КБ). На коротких вызовах GIL + возвращается мгновенно и ускорение пропадает. Сравните block_size=4096 + и block_size=1<<20 в отчёте! + """ + # TODO: данные -> деление на p кусков -> ThreadPoolExecutor -> + # hashlib.sha256(блок).digest() -> checksum -> {p: время} + raise NotImplementedError + + +def gil_status(): + """Вернуть (версия_python, gil_enabled: bool). + + Используйте sys._is_gil_enabled() (Python 3.13+); если атрибута нет, + считайте GIL включённым. + """ + raise NotImplementedError + + +def main(): + v = get_variant(VARIANT_NUMBER) + print(f"Вариант {v['variant']}: {v['describe']}\n") + + ver, gil = gil_status() + print(f"Python {ver}, GIL включён: {gil}\n") + + # --- 1. Чистый Python: S(p) ≈ 1 --------------------------------------- + t_cpu = measure_cpu_threads(v["task_name"], v["cpu_params"], v["p_list"]) + print("Чистый Python (GIL не отпускается):") + base = t_cpu[1] + for p, t in sorted(t_cpu.items()): + print(f" p={p}: T={t:.3f} c S={base / t:.2f}") + + # --- 2. hashlib: S(p) > 1 --------------------------------------------- + t_hash = measure_gil_release(v["p_list"]) + print("\nhashlib SHA-256 (GIL отпускается, блоки 1 МБ):") + base = t_hash[1] + for p, t in sorted(t_hash.items()): + print(f" p={p}: T={t:.3f} c S={base / t:.2f}") + + # --- 2b. Мелкие блоки: ускорение должно исчезнуть ---------------------- + t_small = measure_gil_release(v["p_list"], block_size=4096, + blocks_per_part=8000) + print("\nhashlib SHA-256 (блоки 4 КБ — GIL возвращается сразу):") + base = t_small[1] + for p, t in sorted(t_small.items()): + print(f" p={p}: T={t:.3f} c S={base / t:.2f}") + + # --- 3. График ---------------------------------------------------------- + save_speedup_plot( + {"чистый Python": sorted(t_cpu.items()), + "hashlib 1 МБ (released GIL)": sorted(t_hash.items()), + "hashlib 4 КБ": sorted(t_small.items())}, + f"GIL: ускорение потоками (Python {ver})", + os.path.join(os.path.dirname(__file__), "speedup.png")) + + # TODO: в отчёт — таблицу серий, график, объяснение разницы кривых, + # ответы на контрольные вопросы из методички. + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab03_gil/методичка.md b/lab03_gil/методичка.md new file mode 100644 index 0000000..74ce5b2 --- /dev/null +++ b/lab03_gil/методичка.md @@ -0,0 +1,119 @@ +# Лабораторная работа 3. GIL: почему потоки не ускоряют CPU-код + +**Время на работу:** 2 пары. **Зависимости:** лабы 1–2. + +## Цель работы + +Экспериментально исследовать GIL (Global Interpreter Lock) и научиться +обходить его тремя способами: процессы, освобождающие GIL C-функции +(`hashlib`), мультипоточность вне GIL (превью `sys._is_gil_enabled`). + +## Теория + +### Что такое GIL + +GIL — мьютекс в CPython, разрешающий исполнять байт-код Python только одному +потоку процесса в каждый момент. Один интерпретатор — один «держатель» GIL. + +Зачем он нужен: упрощает реализацию CPython (счётчики ссылок, внутренние +структуры не защищены отдельными замками) и делает однопоточный код быстрым. +Потоки в CPython — **настоящие** потоки ОС, но байт-код Python они исполняют +по очереди. + +Как поток получает процессор: держатель GIL обязан отпускать его +периодически (по умолчанию каждые ~5 мс, `sys.getswitchinterval()`), и при +блокирующих вызовах (`sleep`, чтение сети/диска). + +### Следствия + +| Тип задачи | Потоки | Почему | +|---|---|---| +| I/O-bound | ускоряют почти идеально | GIL отпускается на время ожидания | +| CPU-bound (чистый Python) | НЕ ускоряют | GIL: байт-код исполняется по очереди | +| CPU-bound (C-код внутри) | МОГУТ ускорять | C-функция может отпустить GIL | + +Третий пункт — самый интересный: библиотеки на C (`hashlib`, `zlib`, +`numpy` во многих операциях) во время длинных вычислений **отпускают GIL**, +и потоки работают по-настоящему параллельно. Тонкость: GIL отпускается на +время **одного вызова** C-функции. Если блоки маленькие (4 КБ), вызовы +слишком короткие и GIL тут же забирается обратно — ускорения не будет. +Блоки ≥ 256 КБ (в работе — 1 МБ) дают честный параллелизм. В лабе это +демонстрирует `hashlib`. + +### Свободнный threading (Python 3.13+, превью) + +Начиная с CPython 3.13 существует экспериментальная сборка без GIL +(free-threaded). В обычном CPython можно проверить, включён ли GIL: +`sys._is_gil_enabled()`. В работе — короткое ознакомление, не основное. + +### Проверка GIL в эксперименте + +Метод: фиксированная CPU-работа `W`. Замеряем T(1), затем ту же работу +на p потоках. Ускорение S = T(1)/T(p): +- S ≈ 1 → GIL не отпускался (чистый Python); +- S ≈ p → GIL отпускался (C-код, sleep). + +## Задание + +CPU-задача — из вашего варианта (`common.tasks`), ядро `kernel` работает на +чистом Python. Заполните `solution.py`: + +1. **`measure_cpu_threads(task_name, params, p_list)`** — для каждого p: + последовательный запуск (p=1, база) и запуск CPU-задачи на p **потоках** + (`ThreadPoolExecutor`, каждый поток — свой кусок). Вернуть + `{p: время}`. Убедиться, что checksum совпадает с последовательным. +2. **`measure_gil_release(p_list, block_size, blocks_per_part)`** — то же, + но работа — SHA-256 над готовыми блоками байтов (освобождает GIL). + Фиксированный общий объём `W = blocks_per_part * max(p_list)` блоков по + `block_size` байт (`os.urandom`), делится между p потоками. Вернуть + `{p: время}`, проверять checksum. +3. **`gil_status()`** — вернуть `(python_version, gil_enabled)` через + `sys._is_gil_enabled()` (если атрибута нет — GIL включён). +4. **`main()`** — таблицы ускорений для обоих случаев + график S(p). + +### Ожидаемые результаты + +- Чистый Python: S(4) ≈ 0.9–1.1 (замедление из-за переключений GIL). +- SHA-256 на блоках 1 МБ: S(4) ≈ 2–4 (реальный параллелизм). +- SHA-256 на блоках 4 КБ: S(4) ≈ 0.8 — GIL не успевает отпускаться. + **Попробуйте оба размера блоков и объясните разницу** — это ядро работы. +- Разница между «чистый Python» и «hashlib 1 МБ» — и есть GIL. + +### Важно про честность замеров + +- объём работы при любом p одинаковый (проверка checksum); +- `time_call(..., repeats=3)`, берём лучшее; +- следите, чтобы данные hashing-задачи были крупными блоками (в банке + задач так и сделано). + +## Контрольные вопросы + +1. Что такое GIL и зачем он в CPython? +2. Почему `time.sleep` в 100 потоках не «съедает» процессор, а 100 + считающих потоков работают как один? +3. Почему `hashlib.sha256` ускоряется потоками, а `sum` по списку — нет? +4. Что изменится в Python 3.13+ free-threaded сборках? Найдите статус PEP 703. +5. Как проверить в вашем эксперименте, что работа распределена честно? + +## Что сдаётся + +`solution.py` + отчёт: таблица S(p) для чистого Python и для hashlib, +график с обеими кривыми и «идеальной» прямой, вывод: когда потоки полезны. +Ответы на контрольные вопросы. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| Корректный замер чистого Python (S ≈ 1) | 2 | +| Корректный замер hashlib (S(p) > 1.5 при p=4) | 2 | +| `gil_status` и версия Python в отчёте | 1 | +| График с тремя кривыми | 2 | +| Анализ и ответы на вопросы | 3 | + +## Типичные ошибки + +- Слишком мелкие блоки для hashlib (4 КБ): S ≈ 0.8, а не 3+. GIL отпускается + на время одного вызова — вызовы должны быть долгими (блоки ≥ 256 КБ). +- Разный объём работы при разных p — обязательно проверяйте checksum. +- Замер на ноутбуке без питания — турбо-частоты скачут, S(p) «плавает». diff --git a/lab04_multiprocessing/examples/01_spawn_demo.py b/lab04_multiprocessing/examples/01_spawn_demo.py new file mode 100644 index 0000000..fba6124 --- /dev/null +++ b/lab04_multiprocessing/examples/01_spawn_demo.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""Демо: spawn — каждый процесс заново импортирует модуль. + +Запуск из корня курса: + python lab04_multiprocessing/examples/01_spawn_demo.py + +Показывает, что на Windows/spawn дочерний процесс заново выполняет +модуль верхнего уровня. Поэтому защита __main__ обязательна. +Здесь же видно время старта процесса. +""" +import multiprocessing +import os +import sys +import time + +print(f"[импорт модуля] pid={os.getpid()}, parent={os.getppid()}, " + f"метод={multiprocessing.get_start_method()}") + + +def worker(x): + return x * x + + +def main(): + print(f"\n[главный процесс] pid={os.getpid()}") + ctx = multiprocessing.get_context() # spawn на Windows и macOS + + t0 = time.perf_counter() + with ctx.Pool(2) as pool: + results = pool.map(worker, range(8)) + dt = time.perf_counter() - t0 + + print(f"pool.map -> {results}") + print(f"время (включая старт 2 процессов): {dt * 1000:.1f} мс") + print("\nОбратите внимание: строка [импорт модуля] могла напечататься") + print("дважды-трижды (для каждого воркера при spawn). На fork — один раз.") + print("Вывод: старт воркеров не бесплатный — это накладные расходы лабы 4.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab04_multiprocessing/examples/02_pickle_demo.py b/lab04_multiprocessing/examples/02_pickle_demo.py new file mode 100644 index 0000000..df4b90a --- /dev/null +++ b/lab04_multiprocessing/examples/02_pickle_demo.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""Демо: что можно, а что нельзя передать в Pool (pickle). + +Запуск из корня курса: + python lab04_multiprocessing/examples/02_pickle_demo.py + +Вложенные функции и лямбды не пиккелятся — PicklingError. +Модульные функции — можно. Отсюда правило: kernel для Pool живёт +на уровне модуля (как dispatch_kernel в common.tasks). +""" +import multiprocessing +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from common.tasks import dispatch_kernel, smoke_params + + +def module_level_double(x): # МОЖНО: функция уровня модуля + return x * 2 + + +def make_nested(): + def nested_double(x): # НЕЛЬЗЯ: вложенная функция + return x * 2 + return nested_double + + +def main(): + ctx = multiprocessing.get_context() + + print("--- модульная функция: работает ---") + with ctx.Pool(2) as pool: + print(f" pool.map(module_level_double, [1,2,3]) = " + f"{pool.map(module_level_double, [1, 2, 3])}") + + print("\n--- lambda: PicklingError ---") + try: + with ctx.Pool(2) as pool: + pool.map(lambda x: x * 2, [1, 2, 3]) + except Exception as e: + print(f" {type(e).__name__}: {str(e)[:90]}...") + + print("\n--- kernel задачи через dispatch_kernel: работает ---") + task_name = "primes" + chunks = None + task = __import__("common.tasks", fromlist=["TASKS"]).TASKS[task_name] + data = task["build"](smoke_params(task_name)) + chunks = task["split"](data, 2) + payload = [(task_name, ch) for ch in chunks] + with ctx.Pool(2) as pool: + partials = pool.map(dispatch_kernel, payload) + print(f" подсчёт простых ≤ 2000: {task['combine'](partials)} " + f"(ожидалось 303)") + + print("\nВывод: всё, что уходит воркерам, должно пиккелиться —") + print("поэтому в этом курсе kernel диспетчеризуется по имени задачи.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab04_multiprocessing/examples/03_process_queue_demo.py b/lab04_multiprocessing/examples/03_process_queue_demo.py new file mode 100644 index 0000000..4db4149 --- /dev/null +++ b/lab04_multiprocessing/examples/03_process_queue_demo.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""Демо: Process + Queue — ручная организация воркеров. + +Запуск из корня курса: + python lab04_multiprocessing/examples/03_process_queue_demo.py + +Pool — удобный «конвейер». Process+Queue — ручная схема: воркеры сами берут +задачи из очереди. Это основа для producer-consumer в лабе 5. +""" +import multiprocessing +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + + +def worker(task_q, result_q): + """Воркер: берёт задачи, пока не встретит None — «маркер конца».""" + name = multiprocessing.current_process().name + while True: + task = task_q.get() + if task is None: + result_q.put((name, "done")) + break + idx, value = task + time.sleep(0.05) # «полезная работа» + result_q.put((name, f"задача {idx}: {value}² = {value ** 2}")) + + +def main(): + ctx = multiprocessing.get_context() + task_q = ctx.Queue() + result_q = ctx.Queue() + + n_workers = 3 + workers = [ctx.Process(target=worker, args=(task_q, result_q), + name=f"воркер-{i}") + for i in range(n_workers)] + for w in workers: + w.start() + + # кладём задачи + for i in range(9): + task_q.put((i, i + 1)) + # маркеры конца — по одному на воркер + for _ in range(n_workers): + task_q.put(None) + + # собираем результаты + done = 0 + t0 = time.perf_counter() + while done < n_workers: + name, msg = result_q.get() + if msg == "done": + done += 1 + print(f" [{name}] завершился") + else: + print(f" [{name}] {msg}") + for w in workers: + w.join() + print(f"время: {time.perf_counter() - t0:.2f} c (9 задач по 0.05 c на 3 воркерах)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab04_multiprocessing/solution.py b/lab04_multiprocessing/solution.py new file mode 100644 index 0000000..a5c02a7 --- /dev/null +++ b/lab04_multiprocessing/solution.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 4: мультипроцессорный код. + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab04.py, запускаются через подпроцесс). + +Запуск из корня курса: + python lab04_multiprocessing/solution.py +""" +import multiprocessing +import json +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.benchmark import make_table, save_speedup_plot, time_call +from common.tasks import TASKS, dispatch_kernel, get_variant + +# TODO: впишите свой номер в журнале (1..20) +VARIANT_NUMBER = 0 + + +def _sum_list(data): + """Функция уровня модуля — суммирует список (для measure_overhead).""" + return sum(data) + + +def run_cpu_processes(task_name: str, params: dict, p: int): + """CPU-задача на p процессах через Pool и dispatch_kernel. + + Вернуть (checksum, elapsed_seconds). Подсказки: + - payload = [(task_name, chunk) для каждого куска]; + - dispatch_kernel пиккелится, лямбды и вложенные функции — НЕТ; + - ctx = multiprocessing.get_context(); with ctx.Pool(p) as pool: ... + """ + task = TASKS[task_name] + # TODO: build -> split -> pool.map(dispatch_kernel, payload) -> + # combine -> checksum (замерить время) + raise NotImplementedError + + +def measure_overhead(items_count_list): + """Стоимость передачи данных процессу. + + Для каждого n из items_count_list: отправить в воркер список из n чисел + (list(range(n))), воркер возвращает их сумму (_sum_list). Пул создаётся + ОДИН раз, первый вызов — прогрев. Вернуть {n: лучшее_время из 3}. + """ + # TODO: ctx.Pool(1) -> для каждого n: замер pool.map(_sum_list, [data]) + raise NotImplementedError + + +def main(): + # --- smoke-режим для автотестов: быстрый прогон и JSON-отчёт ----------- + # (не удаляйте: тесты запускают solution.py подпроцессом с LAB_SMOKE=1) + if os.environ.get("LAB_SMOKE") == "1": + from common.tasks import run_sequential, smoke_params + name = "integrate" # smoke не зависит от варианта: быстрая задача + sp = smoke_params(name) + expected, _ = run_sequential(name, sp, parts=1) + got, _t = run_cpu_processes(name, sp, p=2) + ok = (got == expected) or (isinstance(got, float) and + abs(got - expected) < 1e-6 * max(1, abs(expected))) + ov = measure_overhead([1_000, 50_000]) + print("LAB_SMOKE_JSON:" + json.dumps({ + "ok": True, "result_ok": bool(ok), "p": 2, + "overhead": {str(k): t for k, t in ov.items()}})) + return + + v = get_variant(VARIANT_NUMBER) + print(f"Вариант {v['variant']}: {v['describe']}") + print(f"метод запуска: {multiprocessing.get_start_method()}\n") + + # --- T(p) на процессах --------------------------------------------------- + rows, times = [], {} + expected = None + for p in v["p_list"]: + t, (cs, _) = time_call(run_cpu_processes, + (v["task_name"], v["cpu_params"], p), repeats=2) + times[p] = t + rows.append((f"процессы, p={p}", t)) + if expected is None: + expected = cs + assert cs == expected or (isinstance(cs, float) and + abs(cs - expected) < 1e-6 * max(1, abs(expected))), \ + f"checksum при p={p} не совпал!" + print(make_table(rows, title="CPU-задача на процессах")) + + base = times[1] + for p, t in times.items(): + print(f" S(p={p}) = {base / t:.2f}") + print() + + # --- Накладные расходы передачи ------------------------------------------- + overhead = measure_overhead([1_000, 100_000, 1_000_000]) + print("Стоимость передачи данных процессу (туда+обратно):") + for n, t in sorted(overhead.items()): + print(f" {n:>9,} чисел: {t * 1000:8.1f} мс".replace(",", " ")) + + save_speedup_plot({"процессы": sorted(times.items())}, + f"Ускорение CPU-задачи процессами ({v['describe']})", + os.path.join(os.path.dirname(__file__), "speedup.png")) + + # TODO: в отчёт — S(p) и число физических ядер вашей машины, + # сравните время kernel варианта со временем передачи из measure_overhead. + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab04_multiprocessing/методичка.md b/lab04_multiprocessing/методичка.md new file mode 100644 index 0000000..63aa5f9 --- /dev/null +++ b/lab04_multiprocessing/методичка.md @@ -0,0 +1,118 @@ +# Лабораторная работа 4. Мультипроцессорный код + +**Время на работу:** 2 пары. **Зависимости:** лабы 1–3. + +## Цель работы + +Освоить настоящую параллельность на CPU-задачах: процессы обходят GIL. +Измерить накладные расходы multiprocessing и понять, когда они съедают +выигрыш. + +## Теория + +### Процесс против потока + +Процесс — отдельный экземпляр интерпретатора со **своей** памятью. У каждого +процесса свой GIL, поэтому CPU-задачи на p процессах исполняются по-настоящему +параллельно (на p ядрах). Цена: создание процесса ~10–100 мс (против ~0.1 мс +у потока), а любые данные передаются между процессами через **pickle** — +сериализацию. Большие данные (матрицы, списки миллионов чисел) передаются +долго, и это становится главным ограничением. + +### Методы запуска: fork и spawn + +- **fork** (Linux/macOS): дочерний процесс — копия родителя «снимком» памяти. + Быстро, но опасно с потоками и блокировками. +- **spawn** (Windows, по умолчанию на macOS): запускается новый интерпретатор, + который заново импортирует ваш модуль. Медленнее и строже: **весь код + создания процессов должен быть под `if __name__ == "__main__":`**, иначе + бесконечное размножение процессов. + +Проверить и выбрать: `multiprocessing.get_start_method()` / +`multiprocessing.get_context("spawn")`. + +### Pool и передаваемые функции + +`multiprocessing.Pool(p)` — пул из p воркеров-процессов: + +```python +with multiprocessing.Pool(p) as pool: + results = pool.map(kernel, chunks) # как map, но по процессам +``` + +ВАЖНО: `kernel` и `chunks` должны пиккелиться. Вложенные функции (def внутри +def) и лямбды — **не** пиккелятся. Поэтому в `common.tasks` ядро задачи +вызывается через модульный `dispatch_kernel((name, chunk))`. + +### Что именно передаётся + +Схема split → kernel → combine в мире процессов означает: +- `chunks` сериализуются и уходят воркерам (расход на передачу); +- частичные результаты сериализуются и возвращаются (расход на возврат); +- если данные больше результата — передача может занять дольше, чем счёт + (см. задачу «сортировка» в банке задач). + +### Разные способы организации + +- `Pool.map` — распределить список по воркерам; +- `Process` + `Queue` — ручная схема: воркеры берут задачи из очереди и кладут + ответы в другую (задел для лабы 5); +- `ProcessPoolExecutor` —ThreadPoolExecutor-подобный интерфейс для процессов. + +## Задание + +CPU-задача — из вашего варианта. Заполните `solution.py`: + +1. **`run_cpu_processes(task_name, params, p)`** — CPU-задача на `p` + процессах через `Pool` и `dispatch_kernel`. Вернуть `(checksum, сек)`. + Вся логика — под `if __name__ == "__main__"` (в `main()`). +2. **`measure_overhead(items_count)`** — измерить стоимость передачи: + отправить в воркер список из `items_count` чисел, воркер возвращает его + длину. Время / количество = цена байта. Вернуть `{items_count: сек}`. +3. **`main()`** — T(p) для p из варианта; таблица; график S(p) с «идеальной» + прямой; вывод о накладных расходах. + +### Ожидаемые результаты + +- CPU-задача: S(p) растёт почти линейно до числа физических ядер; + на машинах с гиперторингом S(p_max) может упираться в ~число физ. ядер. +- `measure_overhead`: передача маленьких данных ~миллисекунды, больших — + десятки-сотни мс. Сравните с временем kernel вашего варианта. + +### Важно (Windows) + +- Всё создание пулов — строго внутри `main()`/под `__main__`. +- Не используйте лямбды и вложенные функции как аргументы `pool.map`. +- На macOS/Linux можно сравнить `fork` и `spawn` (`get_context`) — добавьте + в отчёт, если работаете не на Windows. + +## Контрольные вопросы + +1. Почему процессы ускоряют CPU-код, а потоки — нет? +2. Что такое pickle и почему лямбда не может быть аргументом `pool.map`? +3. Замерили T(p=8) на 4-ядерной машине с гиперторингом — ускорение ~4, а не 8. + Почему? +4. Когда передача данных между процессами дороже самих вычислений? +5. Почему на Windows нужен `if __name__ == "__main__":`? + +## Что сдаётся + +`solution.py` + отчёт: таблица T(p), график S(p), замеры overhead передачи, +анализ (до какого p растёт, где упирается), ответы на вопросы. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| Корректный запуск на процессах, checksum совпадает | 2 | +| S(p) > 1.5 при p=4 на CPU-задаче | 2 | +| Замер overhead передачи данных | 2 | +| График + анализ накладных расходов | 2 | +| Ответы на контрольные вопросы | 2 | + +## Типичные ошибки + +- Нет `__main__` — на Windows бесконечный spawn (см. типичные_ошибки п.1). +- Лямбда/вложенная функция в `pool.map` — `PicklingError`. +- Слишком мелкие куски: создание процессов и передача дороже счёта, + S(p) < 1. Увеличьте размер задачи. diff --git a/lab05_sync/examples/01_race_demo.py b/lab05_sync/examples/01_race_demo.py new file mode 100644 index 0000000..08e4101 --- /dev/null +++ b/lab05_sync/examples/01_race_demo.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""Демо: гонка данных — потерянные обновления счётчика. + +Запуск из корня курса: + python lab05_sync/examples/01_race_demo.py + +counter += 1 — три операции (чтение, инкремент, запись). Два потока читают +одно значение, оба пишут — одно увеличение теряется. Lock устраняет гонку. +""" +import os +import sys +import threading +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +N_THREADS = 8 +N_INCR = 100_000 + + +def worker_unsafe(counter): + for _ in range(N_INCR): + value = counter[0] # ЧТЕНИЕ + time.sleep(0) # усиливает переключения: GIL уступается + counter[0] = value + 1 # ЗАПИСЬ (могла потеряться) + + +def worker_safe(counter, lock): + for _ in range(N_INCR): + with lock: # критическая секция + value = counter[0] + counter[0] = value + 1 + + +def main(): + expected = N_THREADS * N_INCR + + counter = [0] + threads = [threading.Thread(target=worker_unsafe, args=(counter,)) + for _ in range(N_THREADS)] + t0 = time.perf_counter() + for t in threads: + t.start() + for t in threads: + t.join() + unsafe_time = time.perf_counter() - t0 + print(f"без синхронизации: {counter[0]:>10,} из {expected:,} " + f"(потеряно {expected - counter[0]:,}) за {unsafe_time:.2f} c") + + counter = [0] + lock = threading.Lock() + threads = [threading.Thread(target=worker_safe, args=(counter, lock)) + for _ in range(N_THREADS)] + t0 = time.perf_counter() + for t in threads: + t.start() + for t in threads: + t.join() + safe_time = time.perf_counter() - t0 + print(f"с Lock: {counter[0]:>10,} из {expected:,} " + f"(идеально) за {safe_time:.2f} c") + print(f"\nРазница времени: {(safe_time - unsafe_time) / unsafe_time * 100:+.0f}%") + print("(Lock может даже ускорить: без него потоки чаще конфликтуют)") + print("Запустите ещё раз — потерянные обновления будут другими:") + print("гонка недетерминирована, этим она и опасна.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab05_sync/examples/02_semaphore_demo.py b/lab05_sync/examples/02_semaphore_demo.py new file mode 100644 index 0000000..9cd8bce --- /dev/null +++ b/lab05_sync/examples/02_semaphore_demo.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""Демо: Semaphore — лимит одновременного доступа к ресурсу. + +Запуск из корня курса: + python lab05_sync/examples/02_semaphore_demo.py + +Имитация: 10 «клиентов», ресурс допускает 3 одновременных подключения. +Семафор гарантирует: активных клиентов никогда не больше 3. +""" +import os +import sys +import threading +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +MAX_CONN = 3 +N_CLIENTS = 10 +DELAY = 0.2 + +active = 0 +active_lock = threading.Lock() +max_seen = [0] + + +def client(idx, sem): + global active + with sem: # ждём свободный «слот» + with active_lock: + active += 1 + max_seen[0] = max(max_seen[0], active) + print(f" клиент {idx:>2} подключился (активно: {active})") + time.sleep(DELAY) # «работа с ресурсом» + with active_lock: + active -= 1 + print(f" клиент {idx:>2} отключился (активно: {active})") + + +def main(): + sem = threading.Semaphore(MAX_CONN) + threads = [threading.Thread(target=client, args=(i, sem)) + for i in range(N_CLIENTS)] + t0 = time.perf_counter() + for t in threads: + t.start() + for t in threads: + t.join() + total = time.perf_counter() - t0 + + print(f"\nмаксимум одновременно: {max_seen[0]} (лимит {MAX_CONN})") + print(f"время: {total:.2f} c, идеал ≈ {N_CLIENTS / MAX_CONN * DELAY:.2f} c") + print("Если max_seen > лимита — семафор сломан (такого быть не должно).") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab05_sync/examples/03_event_demo.py b/lab05_sync/examples/03_event_demo.py new file mode 100644 index 0000000..2372e68 --- /dev/null +++ b/lab05_sync/examples/03_event_demo.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""Демо: Event — сигнал между потоками. + +Запуск из корня курса: + python lab05_sync/examples/03_event_demo.py + +Потоки-«работники» ждут сигнала готовности (Event.set()), после чего +начинают работу одновременно. Event — как стартовый пистолет. +""" +import os +import sys +import threading +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + + +def worker(idx, start_event, results): + name = f"работник-{idx}" + print(f" {name}: готов, жду сигнала") + start_event.wait() # блокируется, пока set() не вызван + t0 = time.perf_counter() + time.sleep(0.1) # «работа» + results[idx] = (name, time.perf_counter() - t0) + print(f" {name}: закончил") + + +def main(): + start = threading.Event() + results = {} + threads = [threading.Thread(target=worker, args=(i, start, results)) + for i in range(4)] + for t in threads: + t.start() + + time.sleep(0.5) # «подготовка данных» + print("главный: сигнал! Event.set()") + start.set() # все ждущие просыпаются РАЗОМ + + for t in threads: + t.join() + print(f"все закончили: {len(results)} из {len(threads)}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab05_sync/solution.py b/lab05_sync/solution.py new file mode 100644 index 0000000..9289176 --- /dev/null +++ b/lab05_sync/solution.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 5: синхронизация и гонки данных. + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab05.py). + +Запуск из корня курса: + python lab05_sync/solution.py +""" +import os +import queue +import sys +import threading +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.tasks import io_fetch + +# TODO: впишите свой номер в журнале (1..20) — используется в main() +VARIANT_NUMBER = 0 + + +def race_demo(n_threads: int, n_increments: int): + """Гонка данных БЕЗ синхронизации. + + n_threads потоков, каждый увеличивает общий счётчик n_increments раз. + counter += 1 не атомарен! Чтобы гонка проявилась надёжно, после чтения + счётчика добавьте time.sleep(0) — уступает GIL. + Вернуть (фактический_счётчик, ожидаемый = n_threads * n_increments). + """ + # TODO: общий счётчик (список [0] или nonlocal), потоки, start/join + raise NotImplementedError + + +def race_fixed(n_threads: int, n_increments: int): + """То же, но с threading.Lock. Вернуть (фактический_счётчик, ожидаемый). + + Результат обязан быть точным ПРИ ЛЮБЫХ запусках. + """ + raise NotImplementedError + + +def semaphore_limiter(items: int, delay: float, max_concurrent: int): + """Выполнить items запросов io_fetch(item, delay) на потоках, + но не более max_concurrent одновременно (threading.Semaphore). + + Вернуть (список_результатов, elapsed_seconds), упорядочен по item. + Подсказка: results[idx] — каждый воркер пишет в СВОЮ ячейку списка. + """ + raise NotImplementedError + + +def producer_consumer(n_producers: int, n_consumers: int, n_items: int): + """Схема producer-consumer на queue.Queue. + + Производители суммарно кладут задачи со значениями 0..n_items-1 + (распределите между производителями как удобно, но все значения ровно + по одному разу). Потребители забирают и «обрабатывают» (суммируют). + Программа обязана завершиться сама: маркеры конца (None) — по одному + на потребителя. Вернуть (обработано_задач, сумма_значений). + """ + # TODO: queue.Queue() для задач и результатов; producers кладут; + # consumers берут до None; join всех; собрать результаты + raise NotImplementedError + + +def main(): + print("=== 1. Гонка данных ===") + for attempt in range(3): + got, expected = race_demo(8, 100_000) + print(f"попытка {attempt + 1}: {got:,} из {expected:,} " + f"(потеряно {expected - got:,})") + got, expected = race_fixed(8, 100_000) + print(f"с Lock: {got:,} из {expected:,} (всегда точно)") + + print("\n=== 2. Семафор: лимит соединений ===") + v_items, v_delay = 24, 0.05 + for mc in (2, 4, 8): + res, t = semaphore_limiter(v_items, v_delay, mc) + ideal = v_items / mc * v_delay + print(f"max_concurrent={mc}: {len(res)} запросов за {t:.2f} c " + f"(идеал ≈ {ideal:.2f} c)") + + print("\n=== 3. Producer–consumer ===") + t0 = time.perf_counter() + processed, total_sum = producer_consumer(2, 3, 30) + print(f"производителей=2, потребителей=3, задач=30: " + f"обработано {processed}, сумма {total_sum}, " + f"время {time.perf_counter() - t0:.2f} c") + print("Если программа зависла — дедлок: проверьте маркеры конца!") + + # TODO: в отчёт — несколько запусков race_demo (недетерминизм!), + # цена Lock, таблицу semaphore_limiter, схему producer-consumer. + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab05_sync/методичка.md b/lab05_sync/методичка.md new file mode 100644 index 0000000..bf64f19 --- /dev/null +++ b/lab05_sync/методичка.md @@ -0,0 +1,115 @@ +# Лабораторная работа 5. Синхронизация и гонки данных + +**Время на работу:** 2 пары. **Зависимости:** лабы 2, 4. + +## Цель работы + +Увидеть гонку данных своими глазами (без синхронизации результат «плавает»), +научиться защищать общие ресурсы (`Lock`, `Semaphore`, `Event`) и собрать +классическую схему producer–consumer без дедлоков. + +## Теория + +### Гонка данных + +Гонка — ситуация, когда результат зависит от порядка доступа потоков к общим +данным. Классика: `counter += 1` — это **три** операции (прочитать, +увеличить, записать). Два потока могут прочитать одно значение, оба +увеличить, оба записать — одно из двух увеличений потеряется. + +Важно: `x += 1` в Python **не атомарен**. Атомарны отдельные операции вроде +`list.append` (спорно, но под GIL обычно надёжно) — полагаться на это нельзя. + +### Инструменты синхронизации + +| Инструмент | Что делает | Когда нужен | +|---|---|---| +| `Lock` | взаимное исключение: только один в критической секции | защита счётчиков, списков | +| `RLock` | Lock, который можно взять повторно тем же потоком | рекурсивные функции | +| `Semaphore(n)` | разрешает не более n одновременных входов | лимит на соединения/ресурс | +| `Event` | флаг «событие произошло»: `set()` / `wait()` | сигнал между потоками | +| `Condition` | ожидание условия (`wait`/`notify`) | сложные очереди | +| `Barrier(n)` | все ждут, пока соберутся n | синхронный старт | + +В multiprocessing те же примитивы есть в `multiprocessing` (они работают +между процессами через разделяемую память ОС). + +### Producer–consumer + +Классическая схема: производители кладут задачи в очередь, потребители +забирают. `queue.Queue` — потокобезопасна сама по себе (внутри Lock + +Condition). Главные грабли: + +- **Дедлок**: потребитель ждёт `get()`, а производитель уже закончил. + Решение: «маркер конца» (`put(None)`) — по одному на каждого потребителя, + или `get(timeout=...)`/`get_nowait()`. +- **Забытый join** очереди или потоков — программа завершается раньше времени. + +### Как искать гонки + +- Замеры не помогут — гонка «мимикрирует». Помогает: + 1. увеличение числа потоков и повторов; + 2. `time.sleep(0)` внутри критической секции — усиливает переключения; + 3. сравнение результата с эталоном (checksum). + +## Задание + +Заполните `solution.py`: + +1. **`race_demo(n_threads, n_increments)`** — n потоков увеличивают общий + счётчик `n_increments` раз каждый БЕЗ синхронизации. Вернуть + `(фактический_счётчик, ожидаемый)`. Фактический должен быть МЕНЬШЕ + ожидаемого хотя бы иногда — если всегда ровно, увеличьте параметры + и повторите (см. «Как искать гонки»). +2. **`race_fixed(n_threads, n_increments)`** — то же с `threading.Lock`. + Результат обязан быть точным всегда. Замерьте, во что обходится Lock. +3. **`semaphore_limiter(items, delay, max_concurrent)`** — выполнить + `items` «запросов» `io_fetch` на потоках, но не более + `max_concurrent` одновременно (`Semaphore` + ThreadPoolExecutor). + Вернуть `(результаты, сек)`. +4. **`producer_consumer(n_producers, n_consumers, n_items)`** — производители + кладут `n_items` задач суммарно, потребители обрабатывают, программа + завершается без дедлока. Вернуть количество обработанных задач. +5. **`main()`** — демонстрация всех пункто�� с замерами. + +### Ожидаемые результаты + +- `race_demo`: счётчик < ожидаемого (часто сильно); при больших + `n_increments` потери растут. +- `race_fixed`: всегда ровно; время чуть больше (цена Lock). +- `semaphore_limiter`: время ≈ `items / max_concurrent × delay` — лимит + работает. +- `producer_consumer`: обработано ровно `n_items`, завершается сам. + +## Контрольные вопросы + +1. Почему `counter += 1` не атомарен? Какие операции он включает? +2. Чем `Semaphore(3)` отличается от трёх `Lock`? Где это полезно? +3. Что такое дедлок и как «маркер конца» его предотвращает? +4. Почему `queue.Queue` не требует внешнего Lock? +5. Приведите пример из вашей CPU-задачи, где нужна синхронизация при + многопоточности (например, сборка результатов). + +## Что сдаётся + +`solution.py` + отчёт: вывод race_demo (несколько запусков!), сравнение +race_fixed, замеры semaphore_limiter, описание схемы producer–consumer, +ответы на вопросы. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| race_demo демонстрирует потери (или обоснование, почему не демонстрирует) | 2 | +| race_fixed даёт точный результат всегда | 2 | +| semaphore_limiter: лимит соблюдается, замеры есть | 2 | +| producer–consumer завершается сам, ничего не теряется | 2 | +| Отчёт и ответы на вопросы | 2 | + +## Типичные ошибки + +- race_demo всегда даёт точный результат → увеличьте n_increments (10⁵+), + число потоков, добавьте `time.sleep(0)` после чтения счётчика. +- producer–consumer висит → забыли маркеры конца или не join() потоки. +- `Lock` взят внутри `with` другого `Lock` в другом порядке → дедлок + (порядок взятия замков должен быть единым). \ No newline at end of file diff --git a/lab06_asyncio/examples/01_async_basics_demo.py b/lab06_asyncio/examples/01_async_basics_demo.py new file mode 100644 index 0000000..591653b --- /dev/null +++ b/lab06_asyncio/examples/01_async_basics_demo.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""Демо: asyncio — конкурентность на одном потоке. + +Запуск из корня курса: + python lab06_asyncio/examples/01_async_basics_demo.py + +Три корутины «ждут сеть» одновременно: 0.6 c ожидания занимают 0.3 c. +Всё на одном потоке — переключения только на await. +""" +import asyncio +import time + + +async def fetch(name: str, delay: float) -> str: + print(f" {name}: старт") + await asyncio.sleep(delay) # «ожидание сети» — уступает циклу + print(f" {name}: готов") + return name + + +async def main(): + print("--- gather: три корутины конкурентно ---") + t0 = time.perf_counter() + results = await asyncio.gather( + fetch("A", 0.3), + fetch("B", 0.2), + fetch("C", 0.1), + ) + print(f"результаты: {results}") + print(f"время: {time.perf_counter() - t0:.2f} c " + f"(сумма ожиданий 0.6 c)\n") + + print("--- create_task: запустить сейчас, ждать позже ---") + t0 = time.perf_counter() + task = asyncio.create_task(fetch("фоновая", 0.3)) + await asyncio.sleep(0.1) # пока она ждёт — занимаемся своим + print(" главное: что-то делаю, пока фоновая ждёт") + await task + print(f"время: {time.perf_counter() - t0:.2f} c (задачи шли вместе)") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/lab06_asyncio/examples/02_blocking_mistake_demo.py b/lab06_asyncio/examples/02_blocking_mistake_demo.py new file mode 100644 index 0000000..7cc43ed --- /dev/null +++ b/lab06_asyncio/examples/02_blocking_mistake_demo.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""Демо: блокирующий сон убивает event loop. + +Запуск из корня курса: + python lab06_asyncio/examples/02_blocking_mistake_demo.py + +time.sleep (в отличие от asyncio.sleep) НЕ уступает управление: +все корутины встают в очередь. Классическая ошибка в async-коде. +""" +import asyncio +import time + + +async def worker_async(name: str, delay: float) -> str: + await asyncio.sleep(delay) # уступает: другие работают + return name + + +async def worker_blocking(name: str, delay: float) -> str: + time.sleep(delay) # НЕ уступает: парализует loop + return name + + +async def run_all(worker, delay: float) -> float: + t0 = time.perf_counter() + await asyncio.gather(*(worker(f"w{i}", delay) for i in range(5))) + return time.perf_counter() - t0 + + +async def main(): + t = await run_all(worker_async, 0.2) + print(f"asyncio.sleep: 5 задач за {t:.2f} c (идеал ≈ 0.2 c)") + + t = await run_all(worker_blocking, 0.2) + print(f"time.sleep: 5 задач за {t:.2f} c (последовательные 1.0 c!)") + print("\nВывод: в корутине можно вызывать только неблокирующие версии") + print("операций. time.sleep -> asyncio.sleep, requests -> aiohttp и т.д.") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/lab06_asyncio/examples/03_semaphore_demo.py b/lab06_asyncio/examples/03_semaphore_demo.py new file mode 100644 index 0000000..0d185a7 --- /dev/null +++ b/lab06_asyncio/examples/03_semaphore_demo.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""Демо: asyncio.Semaphore — ограничение конкурентности. + +Запуск из корня курса: + python lab06_asyncio/examples/03_semaphore_demo.py + +50 «запросов», сервер выдерживает 5 одновременных. Семафор не даёт +превысить лимит: время ≈ 50/5 × delay. +""" +import asyncio +import time + + +async def request(i: int, delay: float) -> int: + await asyncio.sleep(delay) + return i + + +async def bounded(i: int, delay: float, sem: asyncio.Semaphore) -> int: + async with sem: # только 5 корутин внутри одновременно + return await request(i, delay) + + +async def main(): + N, DELAY, LIMIT = 50, 0.05, 5 + sem = asyncio.Semaphore(LIMIT) + + t0 = time.perf_counter() + results = await asyncio.gather(*(bounded(i, DELAY, sem) + for i in range(N))) + dt = time.perf_counter() - t0 + print(f"{N} запросов, лимит {LIMIT}: {dt:.2f} c") + print(f"идеал ≈ {N / LIMIT * DELAY:.2f} c") + print(f"результаты упорядочены: {results == list(range(N))}") + print("\nБез семафора было бы ≈ 0.05 c (все сразу) — но так сервер ляжет.") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/lab06_asyncio/solution.py b/lab06_asyncio/solution.py new file mode 100644 index 0000000..09fe0cb --- /dev/null +++ b/lab06_asyncio/solution.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 6: асинхронный код (asyncio). + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab06.py). + +Запуск из корня курса: + python lab06_asyncio/solution.py +""" +import asyncio +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.benchmark import make_table +from common.tasks import get_variant + +# TODO: впишите свой номер в журнале (1..20) +VARIANT_NUMBER = 0 + + +async def fetch_async(item: int, delay: float): + """Корутина: имитация I/O-запроса. + + await asyncio.sleep(delay) — НЕ блокирует цикл событий. + Вернуть (item, item * item). + """ + # TODO: asyncio.sleep + вернуть результат + raise NotImplementedError + + +async def fetch_all_async(items: int, delay: float): + """Все items запросов конкурентно через asyncio.gather. + + Вернуть (список результатов, elapsed_seconds), упорядочен по item. + """ + # TODO: gather по всем item -> сортировка не нужна, gather сохраняет порядок + raise NotImplementedError + + +async def fetch_all_limited(items: int, delay: float, max_concurrent: int): + """То же, но не более max_concurrent одновременно (asyncio.Semaphore). + + Вернуть (список результатов, elapsed_seconds), упорядочен по item. + """ + # TODO: sem = asyncio.Semaphore(...); async with sem: await fetch_async(...) + raise NotImplementedError + + +async def _fetch_blocking(item: int, delay: float): + """«Ошибка новичка»: блокирующий time.sleep внутри корутины. + + time.sleep НЕ уступает управление — парализует весь event loop. + """ + time.sleep(delay) + return (item, item * item) + + +async def _blocking_mistake(items: int, delay: float): + """Все запросы через gather, но с блокирующим сном. Вернуть секунды.""" + # TODO: gather по _fetch_blocking, вернуть время + raise NotImplementedError + + +def measure_blocking_mistake(items: int, delay: float) -> float: + """Обёртка для тестов: asyncio.run + return секунд.""" + # TODO: return asyncio.run(_blocking_mistake(items, delay)) + raise NotImplementedError + + +def main(): + v = get_variant(VARIANT_NUMBER) + items, delay = v["io"]["items"], v["io"]["delay"] + print(f"Вариант {v['variant']}: I/O-нагрузка {items} запросов по {delay} c\n") + + # --- 1. Async: все запросы сразу --------------------------------------- + t0 = time.perf_counter() + results, t_async = asyncio.run(fetch_all_async(items, delay)) + print(f"async, без лимита: {t_async:.3f} c " + f"(упорядочено: {[r[0] for r in results] == list(range(items))})") + + # --- 2. Async с лимитом -------------------------------------------------- + for mc in (4, 8): + res, t_lim = asyncio.run(fetch_all_limited(items, delay, mc)) + print(f"async, лимит {mc}: {t_lim:.3f} c " + f"(идеал ≈ {items / mc * delay:.2f} c)") + + # --- 3. Ошибка: time.sleep в корутине ----------------------------------- + t_block = measure_blocking_mistake(items, delay) + print(f"async + time.sleep (ошибка!): {t_block:.3f} c " + f"≈ последовательные {items * delay:.2f} c\n") + + # --- 4. Сравнение с потоками (лаба 2) ------------------------------------ + from concurrent.futures import ThreadPoolExecutor + from common.tasks import io_fetch + + t0 = time.perf_counter() + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(io_fetch, range(items), [delay] * items)) + t_threads = time.perf_counter() - t0 + + rows = [("async без лимита", t_async), + ("async, лимит 8", t_lim), + ("потоки, p=8", t_threads), + ("последовательно (ошибка time.sleep)", t_block)] + print(make_table(rows, title="Сравнение подходов к I/O")) + + # TODO: в отчёт — таблицу, объяснение разницы потоков и asyncio, + # ответ на вопрос «почему time.sleep убил конкурентность». + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab06_asyncio/методичка.md b/lab06_asyncio/методичка.md new file mode 100644 index 0000000..7bd6ec1 --- /dev/null +++ b/lab06_asyncio/методичка.md @@ -0,0 +1,130 @@ +# Лабораторная работа 6. Асинхронный код (asyncio) + +**Время на работу:** 2 пары. **Зависимости:** лабы 1–2. + +## Цель работы + +Освоить кооперативную многозадачность: `async/await`, event loop, `gather`, +ограничение конкурентности семафором. Понять, чем asyncio отличается от +потоков и когда выбирать её. + +## Теория + +### Модель: один поток, но тысячи задач + +Asyncio — **однопоточная** кооперативная многозадачность. Есть один цикл +событий (event loop) и множество корутин. Корутина выполняется, пока сама +не уступит управление через `await` — это ключевое отличие от потоков, где +ОС переключает задачи принудительно в любой момент (вытесняющая +многозадачность). + +Следствия: +- нет гонок на `counter += 1` между await-ами: код между await-ами + исполняется атомарно (переключение — только на await); +- зато «жадная» корутина без await блокирует всё; +- одна корутина может ожидать тысячи сокетов — накладные расходы на задачу + в разы меньше, чем на поток. + +### Ключевые слова и функции + +```python +async def fetch(i): # корутина — «ленивая» функция + await asyncio.sleep(0.05) # уступает управление на время ожидания + return i + +async def main(): + results = await asyncio.gather( # запустить все и ждать все + fetch(1), fetch(2), fetch(3)) + task = asyncio.create_task(fetch(4)) # задача: стартовала сейчас + r = await task # ...а результат ждём тут +``` + +`asyncio.run(main())` — вход в async-мир из обычного кода (один раз на +программу). `await` можно ставить только внутри `async def`. + +### gather и семафор + +`gather` запускает все корутины конкурентно. Если задач тысячи, а «сервер» +выдерживает 100 соединений — используем `asyncio.Semaphore`: + +```python +sem = asyncio.Semaphore(100) + +async def limited(i): + async with sem: + return await fetch(i) +``` + +### Потоки или asyncio? + +| | Потоки | asyncio | +|---|---|---| +| Переключение | вытесняющее (ОС) | кооперативное (await) | +| Масштаб задач | десятки–сотни | тысячи и больше | +| Гонки данных | да | только между await-ами | +| CPU-код | бесполезен (GIL) | блокирует всё | +| Блокирующие библиотеки | подходят | блокируют цикл (!) | + +Критерий: много I/O + нужно много одновременных задач → asyncio. +CPU-работа → процессы (лаба 4). Смешанная → комбинировать. + +### Чем опасна блокирующая функция в async-коде + +`time.sleep(0.05)` (не `asyncio.sleep`) внутри корутины останавливает ВЕСЬ +цикл событий: ни одна другая корутина не выполняется. В работе это +демонстрируется явно. + +## Задание + +Заполните `solution.py` (все функции `async`, кроме `main`): + +1. **`fetch_async(item, delay)`** — корутина: `await asyncio.sleep(delay)`, + вернуть `(item, item * item)`. +2. **`fetch_all_async(items, delay)`** — конкурентно все items запросов + через `asyncio.gather`. Вернуть `(результаты, сек)`, упорядочены по item. +3. **`fetch_all_limited(items, delay, max_concurrent)`** — то же, но + не более max_concurrent одновременно (`asyncio.Semaphore`). +4. **`measure_blocking_mistake(items, delay)`** — «ошибка новичка»: + корутина использует `time.sleep(delay)` вместо `asyncio.sleep`. + Вернуть время выполнения: оно окажется ≈ items × delay — блокирующий + сон парализует цикл событий. +5. **`main()`** — все пункты + сравнение с потоками (лаба 2). + +### Ожидаемые результаты + +- `fetch_all_async(96, 0.05)` ≈ 0.05–0.1 c — все запросы «одновременно». +- `fetch_all_limited(96, 0.05, 8)` ≈ 0.6 c. +- `measure_blocking_mistake(96, 0.05)` ≈ 4.8 c — блокирующий sleep убил + конкурентность. + +## Контрольные вопросы + +1. Чем кооперативная многозадачность отличается от вытесняющей? +2. Почему между двумя `await` корутина «владеет» данными без Lock? +3. Что случится, если в корутине вызвать `time.sleep(10)`? Проверьте. +4. Когда выбрали бы потоки, а когда asyncio? Приведите по примеру. +5. Что делает `asyncio.gather` и чем отличается от последовательных `await`? + +## Что сдаётся + +`solution.py` + отчёт: таблица сравнения (последовательно/потоки/async/ +async+лимит/ошибка с time.sleep), анализ, ответы на вопросы. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| fetch_all_async: время ≈ delay, результаты упорядочены | 2 | +| fetch_all_limited: лимит соблюдается | 2 | +| measure_blocking_mistake: продемонстрирована деградация | 2 | +| Сравнительная таблица в отчёте | 2 | +| Ответы на контрольные вопросы | 2 | + +## Типичные ошибки + +- `await` вне `async def` — SyntaxError. +- Забыт `await` перед корутиной: `RuntimeWarning: never awaited`, ничего + не выполняется. +- `asyncio.run()` внутри работающего цикла (Jupyter) — RuntimeError. +- `time.sleep` в корутине — «секретный» последовательный режим (это п.4 + работы, тут это фича). \ No newline at end of file diff --git a/lab07_comparison/solution.py b/lab07_comparison/solution.py new file mode 100644 index 0000000..7fb7ab0 --- /dev/null +++ b/lab07_comparison/solution.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 7 (итоговая): одна задача — четыре подхода. + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab07.py, запускаются через подпроцесс). + +Запуск из корня курса: + python lab07_comparison/solution.py +""" +import asyncio +import json +import multiprocessing +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.benchmark import amdahl, make_table, save_speedup_plot, time_call +from common.tasks import TASKS, dispatch_kernel, get_variant, io_fetch + +# TODO: впишите свой номер в журнале (1..20) +VARIANT_NUMBER = 0 + + +# ========================= CPU-сценарий ==================================== + +def cpu_sequential(task_name: str, params: dict): + """Последовательно (лаба 1). Вернуть (checksum, сек).""" + task = TASKS[task_name] + # TODO: build -> замер времени -> split(data, 1) -> kernel -> combine -> checksum + raise NotImplementedError + + +def cpu_threads(task_name: str, params: dict, p: int): + """На p потоках (лаба 3): ожидание S ≈ 1 из-за GIL. Вернуть (checksum, сек).""" + # TODO: как в лабе 3 — ThreadPoolExecutor, kernel напрямую в воркере + raise NotImplementedError + + +def cpu_processes(task_name: str, params: dict, p: int): + """На p процессах (лаба 4): ожидание S ≈ ядра. Вернуть (checksum, сек).""" + # TODO: как в лабе 4 — ctx.Pool + dispatch_kernel + raise NotImplementedError + + +# ========================= I/O-сценарий ==================================== + +def io_sequential(items: int, delay: float): + """Последовательно (лаба 1). Вернуть (список, сек).""" + # TODO: [io_fetch(i, delay) for i in range(items)] с замером + raise NotImplementedError + + +def io_threads(items: int, delay: float, p: int): + """На p потоках (лаба 2). Вернуть (список, сек).""" + # TODO: ThreadPoolExecutor + pool.map(io_fetch, ..., [delay]*items) + raise NotImplementedError + + +async def _io_async_coro(items: int, delay: float): + """Корутина (лаба 6): gather + asyncio.sleep. Вернуть (список, сек).""" + raise NotImplementedError + + +def io_async(items: int, delay: float): + """Обёртка asyncio.run. Вернуть (список, сек).""" + raise NotImplementedError + + +def io_async_limited(items: int, delay: float, max_concurrent: int = 8): + """Async с семафором (лаба 6). Вернуть (список, сек).""" + raise NotImplementedError + + +# ========================= main ============================================ + +def _smoke(): + """Smoke-режим автотестов: быстрый прогон всех подходов, JSON-отчёт. + (не удаляйте: тесты запускают solution.py подпроцессом с LAB_SMOKE=1)""" + from common.tasks import smoke_params, run_sequential + name, sp = "integrate", smoke_params("integrate") + exp, _ = run_sequential(name, sp, parts=1) + + cs1, _ = cpu_threads(name, sp, 2) + cs2, _ = cpu_processes(name, sp, 2) + ok_cpu = (cs1 == exp or abs(cs1 - exp) < 1e-6) and \ + (cs2 == exp or abs(cs2 - exp) < 1e-6) + + res, t_seq = io_sequential(8, 0.0) + res2, t_thr = io_threads(8, 0.02, 4) + res3, t_asy = io_async(8, 0.02) + ok_io = len(res) == 8 and len(res2) == 8 and len(res3) == 8 + + print("LAB_SMOKE_JSON:" + json.dumps({ + "ok": True, "cpu_ok": bool(ok_cpu), "io_ok": bool(ok_io), + "p": 2, "io_times": {"seq": t_seq, "threads": t_thr, "async": t_asy}})) + return + + +def main(): + if os.environ.get("LAB_SMOKE") == "1": + _smoke() + return + + v = get_variant(VARIANT_NUMBER) + items, delay = v["io"]["items"], v["io"]["delay"] + print(f"Вариант {v['variant']}: {v['describe']}") + print(f"I/O: {items} запросов по {delay} c\n") + + # --- CPU-сценарий ------------------------------------------------------- + print("=== CPU-сценарий ===") + cs0, t_seq = cpu_sequential(v["task_name"], v["cpu_params"]) + print(f"последовательно: {t_seq:.3f} c") + cpu_results = {"потоки": [], "процессы": []} + for p in v["p_list"]: + _, (c1, t_thr) = time_call(cpu_threads, + (v["task_name"], v["cpu_params"], p), repeats=2) + _, (c2, t_proc) = time_call(cpu_processes, + (v["task_name"], v["cpu_params"], p), repeats=2) + assert (c1 == cs0 or abs(c1 - cs0) < 1e-6) and \ + (c2 == cs0 or abs(c2 - cs0) < 1e-6), f"checksum не совпал при p={p}" + cpu_results["потоки"].append((p, t_thr)) + cpu_results["процессы"].append((p, t_proc)) + print(f"p={p}: потоки {t_thr:.3f} c (S={t_seq/t_thr:.2f}) | " + f"процессы {t_proc:.3f} c (S={t_seq/t_proc:.2f})") + + # --- I/O-сценарий --------------------------------------------------------- + print("\n=== I/O-сценарий ===") + _, t_ioseq = io_sequential(items, delay) + _, t_iothr = io_threads(items, delay, 8) + _, t_ioasy = io_async(items, delay) + _, t_ioasy_lim = io_async_limited(items, delay, 8) + io_rows = [("последовательно", t_ioseq), + ("потоки, p=8", t_iothr), + ("async", t_ioasy), + ("async, лимит 8", t_ioasy_lim)] + print(make_table(io_rows, title="I/O-сценарий")) + + # --- График CPU ------------------------------------------------------------ + save_speedup_plot(cpu_results, + f"CPU: ускорение ({v['describe']})", + os.path.join(os.path.dirname(__file__), "speedup_cpu.png")) + + # --- Оценка s по Амдалу ----------------------------------------------------- + p_max = max(v["p_list"]) + s_meas = t_seq / cpu_results["процессы"][-1][1] + best_s, best_diff = None, float("inf") + for i in range(1, 200): # перебираем s = 0.005 .. 0.995 + s = i / 200 + diff = abs(amdahl(s, p_max) - s_meas) + if diff < best_diff: + best_diff, best_s = diff, s + print(f"\nИзмеренное S({p_max}) = {s_meas:.2f}; " + f"по закону Амдала это соответствует s ≈ {best_s:.3f} " + f"(доля последовательного кода с накладными расходами)") + + # TODO: в отчёт — обе таблицы, график, объяснение s, развёрнутые выводы + # по всему курсу (это итоговая работа). + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab07_comparison/методичка.md b/lab07_comparison/методичка.md new file mode 100644 index 0000000..7c9d20a --- /dev/null +++ b/lab07_comparison/методичка.md @@ -0,0 +1,110 @@ +# Лабораторная работа 7. Сравнительный анализ: одна задача — четыре подхода + +**Время на работу:** 2 пары. **Зависимости:** лабы 1–6. Итоговая работа. + +## Цель работы + +Свести весь курс в одну таблицу: ваша CPU-задача и ваша I/O-нагрузка, +выполненные последовательно, потоками, процессами и асинхронно. Объяснить +каждую цифру. Это работа для защиты: по ней проверяется понимание всего +курса. + +## Теория + +### Ускорение и эффективность + +- Ускорение: **S(p) = T(1) / T(p)** — во сколько раз быстрее на p воркерах. +- Эффективность: **E(p) = S(p) / p** — насколько эффективно используется + каждый воркер. E = 1 — идеал, E = 0.5 — половина мощности простаивает. + +### Закон Амдала + +Если доля последовательного кода задачи равна s (0 ≤ s ≤ 1), то ускорение +ограничено: + +``` +S(p) ≤ 1 / (s + (1 − s) / p) +``` + +При s = 0.1 и p → ∞: S ≤ 10. Даже бесконечное число воркеров не даст +больше 10×. Отсюда главный вывод курса: **сначала сокращайте +последовательную часть и накладные расходы, потом наращивайте p.** + +В `common.benchmark` есть `amdahl(s, p)` — сравните свои S(p) с теорией: +зная измеренное S(p), можно оценить эффективную долю последовательной части +вашего эксперимента (создание пулов, pickle, GIL-переключения — всё это +«последовательная» часть). + +### Матрица выбора модели + +| Задача | Потоки | Процессы | asyncio | +|---|---|---|---| +| CPU-bound (чистый Python) | ✗ (GIL) | ✓ | ✗ (блокирует loop) | +| CPU-bound в C-коде (hashlib, numpy) | ✓ | ✓ | ✗ | +| I/O-bound, десятки задач | ✓ | возможно (дорого) | ✓ | +| I/O-bound, тысячи задач | дорого | ✗ | ✓✓ | + +## Задание + +Заполните `solution.py` — оба сценария из ваших лабораторных: + +**CPU-сценарий** (задача из варианта): +1. `cpu_sequential(task_name, params)` — как в лабе 1; +2. `cpu_threads(task_name, params, p)` — как в лабе 3 (S ≈ 1!); +3. `cpu_processes(task_name, params, p)` — как в лабе 4 (S ≈ ядра). + +**I/O-сценарий** (items × delay из варианта): +4. `io_sequential(items, delay)` — как в лабе 1; +5. `io_threads(items, delay, p)` — как в лабе 2; +6. `io_async(items, delay)` — как в лабе 6; +7. `io_async_limited(items, delay, max_concurrent)` — с семафором 8. + +**`main()`** — таблицы для обоих сценариев с T(p), S(p), E(p); два графика +ускорения; оценка доли s по закону Амдала для процессов. + +### Ожидаемые результаты (сверьтесь) + +CPU: потоки S ≈ 1; процессы S(p) ≈ до числа физических ядер. +I/O: последовательный — база; потоки и async — S ≈ p; async без лимита — +минимальное время (нет расходов на потоки); processes для I/O — работает, +но с заметными накладными расходами. + +### Что дополнительно оценить + +- Разница T(1) у процессов против последовательного — цена старта процессов + и pickle. +- По S(p_max) процессов найдите s через `amdahl` (подберите s так, чтобы + теория совпала с практикой) — укажите его в отчёте. + +## Контрольные вопросы + +1. Почему потоки не ускорили CPU-задачу, но ускорили I/O? +2. Почему asyncio на I/O обгоняет потоки? Откуда выигрыш? +3. Какая доля последовательного кода получилась у процессов по Амдалу? + Что в неё входит? +4. Какую модель вы выбрали бы для веб-краулера на 10 000 страниц? + Для рендера видео? Обоснуйте. +5. Что в ваших замерах не совпало с теорией и почему? + +## Что сдаётся + +`solution.py` + отчёт: две таблицы (CPU и I/O) с T/S/E, два графика, +оценка s по Амдалу, развёрнутые выводы (это итоговая работа — выводы +должны покрывать весь курс), ответы на вопросы. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| Все 7 функций работают, checksum корректен | 3 | +| Таблицы и графики для обоих сценариев | 3 | +| Оценка s по закону Амдала с объяснением | 2 | +| Выводы и ответы на вопросы (защита) | 4 | + +## Типичные ошибки + +- Сравнение T(p) разных сценариев при разном объёме работы — объём + фиксирован внутри сценария. +- Забыт smoke-режим `LAB_SMOKE` (автотесты запускают solution.py + подпроцессом — не удаляйте блок в main()). +- Замеры в один запуск без повторов: `time_call(..., repeats=3)`. \ No newline at end of file diff --git a/lab08_mpi/examples/01_hello_mpi.py b/lab08_mpi/examples/01_hello_mpi.py new file mode 100644 index 0000000..31c229a --- /dev/null +++ b/lab08_mpi/examples/01_hello_mpi.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""Демо: привет, MPI — ранги, размер мира, точка-точка. + +Запуск из корня курса: + mpiexec -n 4 python lab08_mpi/examples/01_hello_mpi.py + +Каждый ранг — отдельный процесс с копией программы. Ранги обмениваются +сообщениями: ранг 0 рассылает своё имя всем, каждый отвечает. +Без mpiexec (обычный python) программа работает как единственный ранг. +""" +from mpi4py import MPI + +comm = MPI.COMM_WORLD +rank = comm.Get_rank() +size = comm.Get_size() + +print(f"[до обмена] ранг {rank} из {size}, узел {MPI.Get_processor_name()}") + +if rank == 0: + # ранг 0 рассылает задание всем остальным (bcast: всем одно и то же) + greeting = f"привет от координатора (ранг 0), всего рангов {size}" +else: + greeting = None + +greeting = comm.bcast(greeting, root=0) # коллективная: вызывают ВСЕ +print(f"ранг {rank}: получил «{greeting}»") + +# точка-точка: каждый ранг (кроме 0) отправляет 0 свой номер +if rank != 0: + comm.send(rank, dest=0, tag=11) + print(f"ранг {rank}: отправил свой номер координатору") +else: + received = [comm.recv(source=r, tag=11) for r in range(1, size)] + print(f"ранг 0: получил номера от рангов {received}") + +if size == 1: + print("\nВы запустили без mpiexec — работает один ранг.") + print("Попробуйте: mpiexec -n 4 python lab08_mpi/examples/01_hello_mpi.py") \ No newline at end of file diff --git a/lab08_mpi/examples/02_pingpong_mpi.py b/lab08_mpi/examples/02_pingpong_mpi.py new file mode 100644 index 0000000..97bb6fd --- /dev/null +++ b/lab08_mpi/examples/02_pingpong_mpi.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""Демо: ping-pong и замер стоимости обмена между рангами. + +Запуск из корня курса: + mpiexec -n 2 python lab08_mpi/examples/02_pingpong_mpi.py + +Ранги перекидывают список чисел туда-обратно. Так измеряют латентность и +пропускную способность канала между узлами. +""" +import time + +from mpi4py import MPI + +comm = MPI.COMM_WORLD +rank = comm.Get_rank() +size = comm.Get_size() + +if size < 2: + print("Нужны минимум 2 ранга: mpiexec -n 2 python lab08_mpi/examples/02_pingpong_mpi.py") + raise SystemExit(0) + +ROUNDS = 20 +for n_bytes in (1_000, 100_000, 1_000_000): + data = list(range(n_bytes // 8)) # ~n_bytes байт целых чисел + comm.barrier() # синхронный старт замера + t0 = time.perf_counter() + if rank == 0: + for _ in range(ROUNDS): + comm.send(data, dest=1, tag=0) + comm.recv(source=1, tag=0) + elif rank == 1: + for _ in range(ROUNDS): + comm.recv(source=0, tag=0) + comm.send(data, dest=0, tag=0) + dt = time.perf_counter() - t0 + + if rank == 0: + per_round = dt / ROUNDS + print(f"{n_bytes:>9,} байт: {ROUNDS} раундов за {dt:.3f} c " + f"=> {per_round * 1000:.2f} мс/раунд (туда-обратно)".replace(",", " ")) + +if rank == 0: + print("\nНа одной машине это IPC (быстро), между узлами — сеть.") + print("Эта цена обмена и есть накладные расходы распределённых вычислений.") \ No newline at end of file diff --git a/lab08_mpi/examples/03_collectives_mpi.py b/lab08_mpi/examples/03_collectives_mpi.py new file mode 100644 index 0000000..50e11a4 --- /dev/null +++ b/lab08_mpi/examples/03_collectives_mpi.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""Демо: коллективные операции scatter / gather / reduce / bcast. + +Запуск из корня курса: + mpiexec -n 4 python lab08_mpi/examples/03_collectives_mpi.py + +Показывает все четыре коллективные операции на маленьком примере. +""" +from mpi4py import MPI + +comm = MPI.COMM_WORLD +rank = comm.Get_rank() +size = comm.Get_size() + +# --- bcast: один -> всем ------------------------------------------------ +config = {"task": "summa_kvadratov", "n": 100} if rank == 0 else None +config = comm.bcast(config, root=0) +print(f"ранг {rank}: config = {config}") + +# --- scatter: СПИСОК КУСКОВ (ровно size штук), ранг i получает i-й -------- +if rank == 0: + # каждый кусок — свой список; scatter ждёт список из size кусков! + numbers = [[i * 10 + j for j in range(10)] for i in range(size)] +else: + numbers = None +my_chunk = comm.scatter(numbers, root=0) # ранг i получает свой кусок +print(f"ранг {rank}: мой кусок {my_chunk[0]}..{my_chunk[-1]} ({len(my_chunk)} шт.)") + +# --- каждый ранг считает СВОЮ часть -------------------------------------- +my_sum = sum(x * x for x in my_chunk) +print(f"ранг {rank}: сумма квадратов моего куска = {my_sum}") + +# --- gather: все части -> список на ранге 0 ------------------------------ +partial_sums = comm.gather(my_sum, root=0) +if rank == 0: + print(f"ранг 0: частичные суммы = {partial_sums}") + print(f"ранг 0: итого через sum(gather) = {sum(partial_sums)}") + +# --- reduce: то же самое, но сразу со свёрткой (проще и быстрее) ---------- +total = comm.reduce(my_sum, op=MPI.SUM, root=0) +if rank == 0: + print(f"ранг 0: итого через reduce(MPI.SUM) = {total}") + print("\ngather+sum и reduce(SUM) дали одно и то же — reduce короче.") + print("В задании mpi_pi вы используете reduce для суммы попаданий.") \ No newline at end of file diff --git a/lab08_mpi/examples/04_pi_reduce_mpi.py b/lab08_mpi/examples/04_pi_reduce_mpi.py new file mode 100644 index 0000000..7f870b2 --- /dev/null +++ b/lab08_mpi/examples/04_pi_reduce_mpi.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +"""Демо: π методом Монте-Карло в MPI (reduce). + +Запуск из корня курса: + mpiexec -n 4 python lab08_mpi/examples/04_pi_reduce_mpi.py + +Каждый ранг бросает свою порцию «точек» со СВОИМ зерном (rank-зависимым), +reduce(SUM) сворачивает попадания. Число рангов не меняет формулу — +меняется только распределение бросков. +""" +import random +import time + +from mpi4py import MPI + +comm = MPI.COMM_WORLD +rank = comm.Get_rank() +size = comm.Get_size() + +N_PER_RANK = 500_000 + + +def count_inside(n: int, seed: int) -> int: + rng = random.Random(seed) # зерно зависит от ранга + inside = 0 + for _ in range(n): + x = rng.random() + y = rng.random() + if x * x + y * y <= 1.0: + inside += 1 + return inside + + +t0 = time.perf_counter() +my_inside = count_inside(N_PER_RANK, seed=1000 + rank) +total_inside = comm.reduce(my_inside, op=MPI.SUM, root=0) +dt = time.perf_counter() - t0 + +if rank == 0: + total = N_PER_RANK * size + pi = 4.0 * total_inside / total + print(f"рангов: {size}, бросков на ранг: {N_PER_RANK:,} (всего {total:,})") + print(f"π ≈ {pi:.6f} (погрешность {abs(pi - 3.141592653589793):.6f})") + print(f"время: {dt:.2f} c") + if size == 1: + print("\nПопробуйте: mpiexec -n 4 python lab08_mpi/examples/04_pi_reduce_mpi.py") + print("Каждый ранг добавляет 500k бросков: точность растёт, время — нет.") \ No newline at end of file diff --git a/lab08_mpi/solution.py b/lab08_mpi/solution.py new file mode 100644 index 0000000..21e138c --- /dev/null +++ b/lab08_mpi/solution.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +"""Лабораторная работа 8: распределённые вычисления (mpi4py). + +Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают +автотесты (tests/test_lab08.py; если mpi4py не установлен — тесты +пропускаются, но работа сдаётся с запуском через mpiexec). + +Запуск из корня курса: + python lab08_mpi/solution.py (один ранг — отладка) + mpiexec -n 4 python lab08_mpi/solution.py (4 ранга — замеры) +""" +import os +import random +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from common.tasks import TASKS, get_variant + +try: + from mpi4py import MPI + HAVE_MPI = True +except ImportError: + HAVE_MPI = False + +# TODO: впишите свой номер в журнале (1..20) +VARIANT_NUMBER = 0 + + +def _group_for_ranks(chunks, size): + """Распределить куски по рангам round-robin: список из size групп. + + scatter ожидает список ровно из size элементов, а естественных кусков + может быть другое число (например, nqueens всегда даёт N кусков) — + поэтому группируем. + """ + # TODO: groups = [[] for _ in range(size)]; groups[i % size].append(ch) + raise NotImplementedError + + +def mpi_run(task_name: str, params: dict): + """CPU-задача на всех рангах: scatter групп -> kernel -> gather -> combine. + + Схема: + ранг 0: build -> split(data, size) -> _group_for_ranks + scatter раздаёт ГРУППЫ кусков (по одной на ранг) + каждый ранг: my_partials = [task["kernel"](ch) for ch in my_group] + gather собирает списки частичных результатов + ранг 0: развернуть в плоский список -> combine ОДИН раз -> checksum + + Функции пиккелить не нужно (в отличие от multiprocessing) — только данные. + + Для задачи 'life': данные — (grid, steps), параллелятся ПОКОЛЕНИЯ. + Ранг 0 держит текущее поле; каждый шаг: scatter полос -> kernel -> + gather -> combine -> новое поле. Число шагов раздаётся через bcast. + """ + if not HAVE_MPI: + raise RuntimeError("mpi4py не установлен (см. методичку, раздел Установка)") + task = TASKS[task_name] + comm = MPI.COMM_WORLD + rank = comm.Get_rank() + size = comm.Get_size() + + if task_name == "life": + # TODO: цикл по поколениям (см. схему выше); в конце bcast поля + # с ранга 0 всем, чтобы checksum был у всех рангов + raise NotImplementedError + + # TODO: общий случай по схеме выше; вернуть checksum на ранге 0 (None на других) + raise NotImplementedError + + +def mpi_pi(N: int) -> float: + """π методом Монте-Карло на всех рангах (reduce). + + Каждый ранг бросает N//size точек с зерном random.Random(100 + rank). + reduce(MPI.SUM) сворачивает попадания на ранге 0. + Вернуть π (на ранге 0 — вычисленное значение, на остальных 0.0). + """ + # TODO: my_n = N // size; цикл бросков; comm.reduce(inside, op=MPI.SUM, root=0) + raise NotImplementedError + + +def main(): + if not HAVE_MPI: + print("mpi4py не установлен. Инструкция — в методичке, раздел «Установка».") + print("Кратко (Windows): MS-MPI runtime + pip install mpi4py") + sys.exit(1) + + v = get_variant(VARIANT_NUMBER) + comm = MPI.COMM_WORLD + rank = comm.Get_rank() + size = comm.Get_size() + + if rank == 0: + print(f"Вариант {v['variant']}: {v['describe']}") + print(f"Запущено рангов: {size}\n") + + # --- pi через reduce ------------------------------------------------------ + pi = mpi_pi(1_000_000) + if rank == 0: + print(f"π ≈ {pi:.6f} (Монте-Карло, 1 000 000 бросков суммарно)") + + # --- основная задача -------------------------------------------------------- + comm.barrier() + t0 = time.perf_counter() + checksum = mpi_run(v["task_name"], v["cpu_params"]) + dt = time.perf_counter() - t0 + + if rank == 0: + from common.tasks import run_sequential + ref, _ = run_sequential(v["task_name"], v["cpu_params"], parts=1) + ok = checksum == ref or (isinstance(checksum, float) and + abs(checksum - ref) < 1e-6 * max(1, abs(ref))) + print(f"задача: {dt:.3f} c, checksum совпадает с последовательным: {ok}") + print("\nЗапустите при разном числе рангов для таблицы T(p):") + print(" mpiexec -n 1 python lab08_mpi/solution.py") + print(" mpiexec -n 2 python lab08_mpi/solution.py") + print(" mpiexec -n 4 python lab08_mpi/solution.py") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lab08_mpi/методичка.md b/lab08_mpi/методичка.md new file mode 100644 index 0000000..b3fb890 --- /dev/null +++ b/lab08_mpi/методичка.md @@ -0,0 +1,140 @@ +# Лабораторная работа 8. Распределённые вычисления (MPI) + +**Время на работу:** 2–3 пары. **Зависимости:** лабы 1, 4. + +## Цель работы + +Освоить модель передачи сообщений (MPI) — стандарт распределённых вычислений, +на котором работают суперкомпьютеры. Понять отличие «общей памяти» +(потоки/процессы Python) от «обмена сообщениями» между независимыми узлами. + +## Теория + +### Модель MPI + +MPI (Message Passing Interface) — набор программ на нескольких **узлах** +(машинах или процессах), обменивающихся сообщениями. Нет общей памяти: любые +данные передаются явно, функциями отправки/приёма. Программа запускается +одновременно на N «рангах» (процессах): + +```bat +mpiexec -n 4 python script.py # 4 копии script.py +``` + +Каждая копия узнаёт, кто она: + +```python +from mpi4py import MPI +comm = MPI.COMM_WORLD # «мир» — все ранги +rank = comm.Get_rank() # мой номер: 0..size-1 +size = comm.Get_size() # всего рангов +``` + +Ранг 0 обычно координатор: он делит работу и собирает результат. + +### Операции: «точка-точка» и коллективные + +**Точка-точка** (ранг ↔ ранг): + +```python +comm.send(data, dest=1, tag=0) # блокирующая отправка +data = comm.recv(source=0, tag=0) # блокирующий приём +``` + +**Коллективные** (все ранги сразу — проще и обычно быстрее): + +| Операция | Что делает | +|---|---| +| `bcast` | один раздает всем (broadcast) | +| `scatter` | список режется на части: ранг i получает i-ю часть | +| `gather` | части собираются в список на одном ранге | +| `reduce` | части сворачиваются операцией (sum/max/…) на одном ранге | +| `barrier` | все ждут, пока все дойдут до этой точки | + +### Схема параллельной задачи в MPI + +``` +ранг 0: build -> split -> scatter ─┐ +ранги 1..N: получают кусок │-> kernel -> gather -> combine -> checksum +ранг 0: собирает ┘ +``` + +Это ровно ваша схема split→kernel→combine из лаб 1–4, только обмен данными — +через сеть/IPC, а не общую память. + +### Установка (Windows) + +1. MS-MPI: скачать и установить **msmpisetup.exe** и **msmpires.msi** + (https://www.microsoft.com/en-us/download/details.aspx?id=105289). +2. `pip install mpi4py` +3. Проверка: `mpiexec -n 2 python -c "from mpi4py import MPI; print(MPI.COMM_WORLD.Get_rank())"` + +На macOS/Linux: `brew install open-mpi` / `apt install libopenmpi-dev`, +затем `pip install mpi4py`, запуск `mpiexec` (или `mpirun`). + +Важно: запускать `mpiexec -n 4` — тогда `solution.py` выполнится 4 раза +параллельно. Запуск без mpiexec допустим: программа работает как один ранг +(полезно для отладки и автотестов). + +## Задание + +Заполните `solution.py`: + +1. **`mpi_run(task_name, params)`** — параллельное выполнение вашей CPU-задачи: + ранг 0 строит данные и делит на `size` кусков, `scatter` раздаёт, + каждый ранг считает `kernel` (используя `dispatch_kernel` не нужно — + в MPI функции не пиккелятся, только данные), `gather` собирает, + ранг 0 возвращает checksum. Для задачи «Жизнь» — цикл по поколениям + (см. подсказку в заготовке). +2. **`mpi_pi(N)`** — учебный пример: π методом Монте-Карло на всех рангах, + сборка через `reduce` (сумма попаданий). Каждый ранг использует + `random.Random(100 + rank)` — свои броски. +3. **`main()`** — checksum и время `mpi_run` на текущем числе рангов + (`size`), печать с ранга 0. Запустите при `-n 1, 2, 4` — получите T(p). + +### Ожидаемые результаты + +- checksum совпадает с последовательным запуском (лаба 1). +- T(p) на одном компьютере ≈ результатам multiprocessing из лабы 4 + (плюс небольшие расходы на обмен). +- На настоящих нескольких машинах код не меняется — меняется только + запуск (это главный смысл MPI). + +### Измерения для отчёта + +```bat +mpiexec -n 1 python lab08_mpi\solution.py +mpiexec -n 2 python lab08_mpi\solution.py +mpiexec -n 4 python lab08_mpi\solution.py +``` + +## Контрольные вопросы + +1. Чем модель передачи сообщений отличается от модели общей памяти? +2. Что делает `scatter` и чем она отличается от `bcast`? +3. Почему в MPI функции не нужно пиккелить, а в multiprocessing — нужно? +4. Что такое ранг? Как ранг 0 узнаёт, сколько всего рангов? +5. Когда MPI выгоднее multiprocessing, даже на одном компьютере? + +## Что сдаётся + +`solution.py` + отчёт: вывод при `-n 1/2/4`, таблица T(p), сравнение с +лабой 4, ответы на вопросы. + +## Критерии оценки + +| Пункт | Баллы | +|---|---| +| mpi_run: checksum корректен при -n 1 и -n 4 | 3 | +| mpi_pi через reduce | 2 | +| Замеры при разном числе рангов | 2 | +| Отчёт и ответы на вопросы | 3 | + +## Типичные ошибки + +- Код с веткой «если rank == 0» выполняется ВСЕМИ рангами — не заворачивайте + всю программу в `if rank == 0`. +- Разные random на разных рангах без фиксации зерна — невоспроизводимый + результат. +- Заблокированный `recv` без парной `send` — программа висит. Начинайте с + коллективных операций, они безопаснее. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..707b92f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# Обязательные зависимости курса +numpy>=1.24 +matplotlib>=3.7 +pytest>=7.0 + +# Опционально (только для лабораторной работы 8, MPI): +# Windows: установить MS-MPI (https://www.microsoft.com/en-us/download/details.aspx?id=105289), +# затем: pip install mpi4py +# mpi4py>=3.1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..92a344c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""Общие фикстуры и помощники для автотестов курса. + +Запуск из корня курса: python -m pytest tests/ -k lab01 -v +""" +import importlib.util +import math +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def load_solution(lab_dir: str): + """Импортирует solution.py из папки лабораторной как отдельный модуль.""" + path = ROOT / lab_dir / "solution.py" + if not path.exists(): + raise FileNotFoundError(f"не найден {path}") + spec = importlib.util.spec_from_file_location(f"solution_{lab_dir}", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def close(a, b, rel=1e-6, abs_tol=1e-6): + """Сравнение с допуском: для float-результатов (π, интеграл, checksum). + + Принимает как значения, так и кортежи (value, seconds), которые + возвращают функции-заготовки студентов. + """ + if isinstance(a, tuple): + a = a[0] + if isinstance(b, tuple): + b = b[0] + if isinstance(a, int) and isinstance(b, int): + return a == b + return math.isclose(float(a), float(b), rel_tol=rel, abs_tol=abs_tol) + + +def source_of(lab_dir: str) -> str: + """Исходный код solution.py — для проверки использованных механизмов.""" + return (ROOT / lab_dir / "solution.py").read_text(encoding="utf-8") diff --git a/tests/test_lab01.py b/tests/test_lab01.py new file mode 100644 index 0000000..4663819 --- /dev/null +++ b/tests/test_lab01.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 1 (последовательное вычисление).""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from conftest import close, load_solution # noqa: E402 +from common.tasks import TASKS, run_sequential, smoke_params # noqa: E402 + + +def test_variant_number_set(): + mod = load_solution("lab01_sequential") + assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, ( + "Укажите свой VARIANT_NUMBER (1..20) в solution.py") + + +def test_cpu_sequential_correct(): + mod = load_solution("lab01_sequential") + # имя задачи берём из варианта студента + from common.tasks import get_variant + name = get_variant(mod.VARIANT_NUMBER)["task_name"] + sp = smoke_params(name) + + # эталон: последовательный запуск из common + expected, _ = run_sequential(name, sp, parts=1) + # последовательный запуск из 3 кусков тоже обязан совпасть + expected3, _ = run_sequential(name, sp, parts=3) + + got1 = mod.run_cpu_sequential(name, sp, parts=1) + got3 = mod.run_cpu_sequential(name, sp, parts=3) + + assert close(got1, expected), f"checksum(parts=1)={got1}, ожидалось {expected}" + assert close(got3, expected3), ( + f"checksum зависит от разбиения: parts=3 -> {got3}, parts=1 -> {expected}") + + +def test_cpu_sequential_returns_time(): + mod = load_solution("lab01_sequential") + from common.tasks import get_variant + name = get_variant(mod.VARIANT_NUMBER)["task_name"] + res = mod.run_cpu_sequential(name, smoke_params(name), parts=2) + assert isinstance(res, tuple) and len(res) == 2, ( + "run_cpu_sequential должна возвращать (checksum, секунды)") + checksum, seconds = res + assert isinstance(seconds, (int, float)) and seconds >= 0 + + +def test_io_sequential_correct(): + mod = load_solution("lab01_sequential") + results, seconds = mod.run_io_sequential(6, 0.0) + assert results == [(i, i * i) for i in range(6)] + assert seconds >= 0 diff --git a/tests/test_lab02.py b/tests/test_lab02.py new file mode 100644 index 0000000..32c0c37 --- /dev/null +++ b/tests/test_lab02.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 2 (многопоточный код).""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from conftest import load_solution, source_of # noqa: E402 + + +def test_variant_number_set(): + mod = load_solution("lab02_threads") + assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, ( + "Укажите свой VARIANT_NUMBER (1..20) в solution.py") + + +def test_manual_threads_correct(): + mod = load_solution("lab02_threads") + results, seconds = mod.run_io_threads_manual(8, 0.0, p=3) + assert [r[0] for r in results] == list(range(8)), ( + "результаты должны быть упорядочены по item") + assert seconds >= 0 + + +def test_manual_threads_parallel(): + """8 запросов по 0.1 c на 4 потоках должны занять ~0.2 c, не ~0.8 c.""" + mod = load_solution("lab02_threads") + _, seconds = mod.run_io_threads_manual(8, 0.1, p=4) + assert seconds < 0.6, ( + f"выполнено за {seconds:.2f} c — похоже, потоки работают по очереди " + "(start() до join()!)") + + +def test_pool_threads_correct(): + mod = load_solution("lab02_threads") + results, seconds = mod.run_io_threads_pool(8, 0.0, p=3) + assert [r[0] for r in results] == list(range(8)) + assert seconds >= 0 + + +def test_pool_threads_parallel(): + mod = load_solution("lab02_threads") + _, seconds = mod.run_io_threads_pool(8, 0.1, p=4) + assert seconds < 0.6, ( + f"выполнено за {seconds:.2f} c — пул не даёт параллельности") + + +def test_uses_threading(): + src = source_of("lab02_threads") + assert "threading" in src or "ThreadPoolExecutor" in src, ( + "в решении должны использоваться потоки") + + +def test_manual_uses_thread_class(): + src = source_of("lab02_threads") + assert "threading.Thread" in src, ( + "run_io_threads_manual должна использовать threading.Thread") diff --git a/tests/test_lab03.py b/tests/test_lab03.py new file mode 100644 index 0000000..b770c66 --- /dev/null +++ b/tests/test_lab03.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 3 (GIL).""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from conftest import close, load_solution # noqa: E402 +from common.tasks import run_sequential, smoke_params # noqa: E402 + + +def test_variant_number_set(): + mod = load_solution("lab03_gil") + assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, ( + "Укажите свой VARIANT_NUMBER (1..20) в solution.py") + + +def test_run_task_on_threads_correct(): + mod = load_solution("lab03_gil") + from common.tasks import get_variant + name = get_variant(mod.VARIANT_NUMBER)["task_name"] + sp = smoke_params(name) + + expected, _ = run_sequential(name, sp, parts=1) + got, seconds = mod.run_task_on_threads(name, sp, p=4) + + assert close(got, expected), f"checksum {got} != {expected}" + assert seconds >= 0 + + +def test_run_task_on_threads_uses_pool(): + src = (Path(__file__).resolve().parents[1] / "lab03_gil" / "solution.py" + ).read_text(encoding="utf-8") + assert "ThreadPoolExecutor" in src or "threading.Thread" in src, ( + "нужны именно потоки (ThreadPoolExecutor или Thread)") + + +def test_measure_cpu_threads_keys(): + mod = load_solution("lab03_gil") + from common.tasks import get_variant + name = get_variant(mod.VARIANT_NUMBER)["task_name"] + sp = smoke_params(name) + res = mod.measure_cpu_threads(name, sp, [1, 2, 4]) + assert sorted(res.keys()) == [1, 2, 4], f"ключи {sorted(res.keys())}" + assert all(t > 0 for t in res.values()) + + +def test_measure_gil_release_parallel(): + """hashlib на блоках 1 МБ обязан показать S(4) > 1.4.""" + mod = load_solution("lab03_gil") + res = mod.measure_gil_release([1, 4], block_size=1 << 20, blocks_per_part=8) + s4 = res[1] / res[4] + assert s4 > 1.4, ( + f"S(4)={s4:.2f} — hashlib не ускорился потоками. Проверьте размер " + "блоков (нужны ≥ 256 КБ) и что общий объём делится между потоками") + + +def test_gil_status(): + mod = load_solution("lab03_gil") + ver, gil = mod.gil_status() + assert isinstance(ver, str) and ver + assert isinstance(gil, bool) diff --git a/tests/test_lab04.py b/tests/test_lab04.py new file mode 100644 index 0000000..babe05b --- /dev/null +++ b/tests/test_lab04.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 4 (multiprocessing). + +Важная особенность: тесты запускают solution.py как ПОДПРОЦЕСС с переменной +окружения LAB_SMOKE=1 — это одновременно проверяет и корректность, и +Windows-совместимость (наличие if __name__ == "__main__"). +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +LAB_DIR = "lab04_multiprocessing" + + +def run_smoke(): + """Запускает solution.py в подпроцессе в smoke-режиме, возвращает JSON.""" + env = dict(os.environ) + env["LAB_SMOKE"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + proc = subprocess.run( + [sys.executable, str(ROOT / LAB_DIR / "solution.py")], + capture_output=True, text=True, env=env, timeout=120, + cwd=str(ROOT)) + if proc.returncode != 0: + raise AssertionError( + f"solution.py упал:\n--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}") + # smoke-режим печатает одну строку JSON с префиксом LAB_SMOKE_JSON: + for line in proc.stdout.splitlines(): + if line.startswith("LAB_SMOKE_JSON:"): + return json.loads(line[len("LAB_SMOKE_JSON:"):]) + raise AssertionError( + f"в smoke-режиме не найден LAB_SMOKE_JSON. stdout:\n{proc.stdout}") + + +def test_smoke_processes_work(): + """Запуск на процессах из подпроцесса: checksum корректен, p>1 отработал.""" + out = run_smoke() + assert out.get("ok") is True, f"smoke-отчёт: {out}" + assert out.get("result_ok") is True, ( + "checksum параллельного запуска не совпал с последовательным") + assert out.get("p") and out["p"] >= 2, "smoke должен запускаться при p>=2" + + +def test_smoke_overhead_measured(): + out = run_smoke() + ov = out.get("overhead", {}) + assert len(ov) >= 2, "measure_overhead должна вернуть замеры минимум для 2 размеров" + assert all(t > 0 for t in ov.values()) + + +def test_uses_dispatch_kernel(): + src = (ROOT / LAB_DIR / "solution.py").read_text(encoding="utf-8") + assert "dispatch_kernel" in src, ( + "для Pool нужен пиккелируемый dispatch_kernel из common.tasks") + assert 'if __name__ == "__main__"' in src, ( + "обязательна защита if __name__ == '__main__' (Windows/spawn)") diff --git a/tests/test_lab05.py b/tests/test_lab05.py new file mode 100644 index 0000000..d9af967 --- /dev/null +++ b/tests/test_lab05.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 5 (синхронизация).""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from conftest import load_solution # noqa: E402 + + +def test_variant_number_set(): + mod = load_solution("lab05_sync") + assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, ( + "Укажите свой VARIANT_NUMBER (1..20) в solution.py") + + +def test_race_fixed_exact(): + """С Lock результат точен при любых запусках.""" + mod = load_solution("lab05_sync") + for _ in range(3): + got, expected = mod.race_fixed(6, 10_000) + assert got == expected, ( + f"race_fixed потерял обновления: {got} из {expected} — " + "Lock не защищает счётчик") + + +def test_race_demo_type(): + mod = load_solution("lab05_sync") + got, expected = mod.race_demo(4, 5_000) + assert isinstance(got, int) and isinstance(expected, int) + assert expected == 20_000 + assert 0 < got <= expected + + +def test_semaphore_limits(): + """max_concurrent=2 на 10 запросах по 0.1 c должно занять ~0.5 c (не ~0.1).""" + mod = load_solution("lab05_sync") + res, t = mod.semaphore_limiter(10, 0.1, 2) + assert len(res) == 10 + assert t >= 0.45, ( + f"выполнено за {t:.2f} c — лимит {2} одновременных не соблюдается") + # и параллельность работает: на limiter=10 было бы ~0.1 c + res2, t2 = mod.semaphore_limiter(10, 0.1, 10) + assert t2 < t * 0.6, "при большем лимите время должно заметно упасть" + + +def test_producer_consumer_no_deadlock(): + mod = load_solution("lab05_sync") + import signal + + def handler(signum, frame): + raise TimeoutError("producer_consumer не завершился — дедлок!") + + try: + signal.signal(signal.SIGALRM, handler) + signal.alarm(30) + processed, total_sum = mod.producer_consumer(2, 3, 30) + signal.alarm(0) + except (AttributeError, ValueError): + # Windows: SIGALRM нет — просто вызываем + processed, total_sum = mod.producer_consumer(2, 3, 30) + + assert processed == 30, f"обработано {processed} из 30 — задачи потерялись" + assert total_sum == sum(range(30)), ( + f"сумма {total_sum} неверна — данные искажены") diff --git a/tests/test_lab06.py b/tests/test_lab06.py new file mode 100644 index 0000000..a0014a3 --- /dev/null +++ b/tests/test_lab06.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 6 (asyncio).""" +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from conftest import load_solution, source_of # noqa: E402 + +LAB = "lab06_asyncio" + + +def test_variant_number_set(): + mod = load_solution(LAB) + assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, ( + "Укажите свой VARIANT_NUMBER (1..20) в solution.py") + + +def test_fetch_all_async_correct_and_concurrent(): + mod = load_solution(LAB) + results, seconds = asyncio.run(mod.fetch_all_async(8, 0.1)) + assert results == [(i, i * i) for i in range(8)], ( + "результаты должны быть (item, item*item) и упорядочены по item") + assert seconds < 0.5, ( + f"8 запросов по 0.1 c заняли {seconds:.2f} c — конкурентности нет; " + "проверьте, что используется asyncio.sleep и await") + + +def test_fetch_all_limited_respects_limit(): + mod = load_solution(LAB) + results, seconds = asyncio.run(mod.fetch_all_limited(10, 0.1, 2)) + assert results == [(i, i * i) for i in range(10)] + assert seconds >= 0.45, ( + f"10 запросов по 0.1 c при лимите 2 заняли {seconds:.2f} c " + "— лимит не соблюдается (должно быть ~0.5 c)") + # и без лимита было бы ~0.1 c + _, t_all = asyncio.run(mod.fetch_all_limited(10, 0.1, 10)) + assert t_all < seconds * 0.5, "при большем лимите время должно заметно упасть" + + +def test_blocking_mistake_is_sequential(): + """time.sleep в корутине должен дать время ≈ items*delay.""" + mod = load_solution(LAB) + seconds = mod.measure_blocking_mistake(5, 0.1) + assert seconds >= 0.45, ( + f"заняло {seconds:.2f} c вместо ~0.5 c — time.sleep внезапно " + "конкурентен? Проверьте, что в _fetch_blocking именно time.sleep") + + +def test_uses_async_syntax(): + src = source_of(LAB) + assert "async def" in src, "нужны корутины (async def)" + assert "asyncio.gather" in src, "нужен asyncio.gather" diff --git a/tests/test_lab07.py b/tests/test_lab07.py new file mode 100644 index 0000000..497121c --- /dev/null +++ b/tests/test_lab07.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 7 (сводная). + +solution.py запускается подпроцессом с LAB_SMOKE=1 (как в лабе 4), +т.к. в решении используется multiprocessing. +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +LAB_DIR = "lab07_comparison" + + +def run_smoke(): + env = dict(os.environ) + env["LAB_SMOKE"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + proc = subprocess.run( + [sys.executable, str(ROOT / LAB_DIR / "solution.py")], + capture_output=True, text=True, env=env, timeout=120, + cwd=str(ROOT)) + if proc.returncode != 0: + raise AssertionError( + f"solution.py упал:\n--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}") + for line in proc.stdout.splitlines(): + if line.startswith("LAB_SMOKE_JSON:"): + return json.loads(line[len("LAB_SMOKE_JSON:"):]) + raise AssertionError(f"нет LAB_SMOKE_JSON. stdout:\n{proc.stdout}") + + +def test_smoke_all_approaches_work(): + out = run_smoke() + assert out.get("ok") is True + assert out.get("cpu_ok") is True, ( + "CPU-подходы дают неверный checksum (потоки и/или процессы)") + assert out.get("io_ok") is True, "I/O-подходы вернули неверный объём результатов" + + +def test_smoke_io_times_present(): + out = run_smoke() + times = out.get("io_times", {}) + assert all(k in times for k in ("seq", "threads", "async")), times + assert all(t >= 0 for t in times.values()) + + +def test_uses_all_four_models(): + src = (ROOT / LAB_DIR / "solution.py").read_text(encoding="utf-8") + assert "ThreadPoolExecutor" in src, "нужны потоки" + assert "Pool" in src or "ProcessPoolExecutor" in src, "нужны процессы" + assert "asyncio" in src and "gather" in src, "нужен asyncio" + assert 'if __name__ == "__main__"' in src, "нужен __main__-guard" diff --git a/tests/test_lab08.py b/tests/test_lab08.py new file mode 100644 index 0000000..5cb7375 --- /dev/null +++ b/tests/test_lab08.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +"""Автотесты лабораторной 8 (MPI). + +Если mpi4py не установлен — тесты пропускаются (SKIPPED), но сама работа +сдаётся с запуском через mpiexec -n 2/4 (проверяет преподаватель). + +Тесты выполняются в режиме ОДНОГО ранга (без mpiexec): в этом режиме +scatter/gather работают тривиально, но корректность схемы и checksum +проверяются. Многоранговую корректность гарантирует эталон и проверка +преподавателем через mpiexec. +""" +import os +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +mpi4py = pytest.importorskip("mpi4py", reason="mpi4py не установлен") + +from conftest import close, load_solution # noqa: E402 +from common.tasks import run_sequential, smoke_params # noqa: E402 + +LAB = "lab08_mpi" + + +def test_variant_number_set(): + mod = load_solution(LAB) + assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, ( + "Укажите свой VARIANT_NUMBER (1..20) в solution.py") + + +def test_mpi_run_single_rank(): + """Один ранг: checksum должен совпадать с последовательным запуском.""" + mod = load_solution(LAB) + from common.tasks import get_variant + name = get_variant(mod.VARIANT_NUMBER)["task_name"] + sp = smoke_params(name) + + expected, _ = run_sequential(name, sp, parts=1) + got = mod.mpi_run(name, sp) + assert got is not None, "mpi_run должна вернуть checksum (на любом ранге)" + assert close(got, expected), f"checksum {got} != {expected}" + + +def test_mpi_pi(): + mod = load_solution(LAB) + pi = mod.mpi_pi(100_000) + assert 3.0 < pi < 3.3, f"pi = {pi} — вне допустимого диапазона для 100k бросков"