From 0f9b32c78b66e474f53051ca9c186f1f93d1ff29 Mon Sep 17 00:00:00 2001 From: devAsmodeus Date: Thu, 30 Apr 2026 23:57:56 +0300 Subject: [PATCH] feat(bench): pure-Python hashrate benchmark mode Adds `hope-hash --benchmark [--bench-duration SEC]` for measuring the pure-Python SHA-256 baseline before C/Rust/SIMD/GPU optimizations (ROADMAP levels 2-3). Without a number "before", numbers "after" mean nothing. Mechanism: - Reuses existing parallel.start_pool/stop_pool machinery so the measured code path is exactly the same as real mining (incl. mid-state SHA-256 from v0.3.0) - Synthetic 76-byte header_base (same shape as real headers) + target=0 so workers never find a hit and just keep hashing - Periodic progress samples (~5 across the run), final aggregate Output includes platform, Python implementation, CPU info, total hashes, wall time, total H/s, per-worker H/s. Returns BenchResult dataclass for programmatic use. CLI: - --benchmark, --bench-duration SEC - Mutually exclusive with --demo (explicit error) - Reuses --workers for worker count Tests (3): - BenchResult.per_worker_hps math (incl. zero-workers safety) - 1-second smoke run in spawn subprocess: hashes > 0, hashrate > 0, duration close to requested Sample run (Intel i7-12700H, 4 workers, 5s): hashrate: 2.28 MH/s per-worker: 570 KH/s Bumps __version__ to 0.4.0. Tests: 98 -> 101. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 37 ++++++++++ README.md | 31 ++++++++- ROADMAP.md | 2 +- src/hope_hash/__init__.py | 5 +- src/hope_hash/bench.py | 142 ++++++++++++++++++++++++++++++++++++++ src/hope_hash/cli.py | 19 +++++ tests/test_bench.py | 72 +++++++++++++++++++ 7 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 src/hope_hash/bench.py create mode 100644 tests/test_bench.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6569171..6fa1327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ ## [Unreleased] +## [0.4.0] — 2026-04-30 + +### Добавлено +- **Бенчмарк-режим** (`bench.py`): `hope-hash --benchmark [--bench-duration SEC]`. + Меряет pure-Python хешрейт без сети и без шар. Baseline для будущих + C/Rust/SIMD/GPU-оптимизаций (см. ROADMAP уровни 2–3). На Intel i7-12700H + даёт ~570 KH/s на воркер с mid-state SHA-256. +- **Pre-flight валидация BTC-адреса** (`address.py`): bech32 (BIP-173), + bech32m (BIP-350), Base58Check. Mainnet-only. Опечатки и testnet-адреса + отвергаются локально, до сетевого round-trip к пулу. +- **Тесты для StratumClient** (`test_stratum.py`): 15 тестов протокольного + слоя через FakeSocket-фикстуру (subscribe/authorize/notify/set_difficulty/ + set_extranonce/submit/reader_loop). Закрывает крупнейшую неоттестированную + поверхность кода. +- Тест на demo-режим в spawn-подпроцессе. +- Тесты на адресную валидацию (18 шт). +- Тесты на бенчмарк (3 шт). +- Всего **101 тест** (было 64 до v0.3 audit tail). + +### Изменено +- `parallel.stop_pool`: магическое 0.2с-окно drain-а заменено на + drain-until-`queue.Empty` с safety-cap. К моменту вызова все находки + уже в очереди или feeder-буфере воркера. +- `miner.supervisor_loop`: `logger.error()` → `logger.exception()` — + unexpected ошибки теперь пишут полный traceback, а не молча + маскируются однострочным логом. +- `miner.mine`: при потере шара (OSError на reconnect) лог теперь + включает `job_id`/`nonce`/`hash` — оператор может найти запись в + SQLite. Намеренно не ретраим: stale-share может привести к ban'у. +- `stratum.py`: добавлены return-type аннотации, `submit()` параметры + типизированы, `_send` явно бросает OSError при `sock is None`. + +### Исправлено +- `miner.mine`: проверка переполнения `extranonce2_counter` — + `f"{counter:0{en2_size*2}x}"` без проверки молча обрубал старшие + биты при `counter >= 2^(en2_size*8)`. Теперь wrap с warning-логом. + ## [0.3.0] — 2026-04-30 ### Добавлено diff --git a/README.md b/README.md index f2cc3e7..971b007 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ │ ├── block.py ← double_sha256, swap_words, target, merkle │ ├── address.py ← валидация BTC-адресов (bech32/bech32m/Base58Check) │ ├── demo.py ← offline-майнинг (--demo) +│ ├── bench.py ← бенчмарк хешрейта (--benchmark) │ ├── storage.py ← SQLite журнал шаров и сессий │ ├── metrics.py ← Prometheus экспортёр (http.server) │ ├── notifier.py ← Telegram через urllib @@ -100,7 +101,8 @@ ├── test_metrics.py ← 16 тестов на Prometheus экспортёр ├── test_notifier.py ← 16 тестов на Telegram (через mock) ├── test_address.py ← 18 тестов на валидацию BTC-адреса - └── test_stratum.py ← 15 тестов на Stratum-протокол (FakeSocket) + ├── test_stratum.py ← 15 тестов на Stratum-протокол (FakeSocket) + └── test_bench.py ← 3 теста на бенчмарк-режим ``` --- @@ -144,6 +146,31 @@ hope-hash --demo --workers 4 # сколько процессов пе Demo не нуждается в BTC-адресе и не делает никаких сетевых вызовов — удобно для smoke-теста на машине, где нужно убедиться, что multiprocessing-воркеры стартуют корректно. +**Бенчмарк-режим:** + +```bash +hope-hash --benchmark # 10 секунд на cpu_count-1 воркерах +hope-hash --benchmark --bench-duration 30 # длиннее = точнее число +hope-hash --benchmark --workers 1 # baseline для одного ядра +``` + +Меряет pure-Python хешрейт без сети и без шар. Полезно как точка отсчёта перед C/Rust/SIMD-оптимизациями (см. ROADMAP уровни 2–3): без числа «до» сравнивать числа «после» бессмысленно. Пример вывода на Intel i7-12700H, 4 воркера, 5 секунд: + +``` +[bench] platform: Windows-10-10.0.26200-SP0 +[bench] python: 3.11.9 (cpython) +[bench] cpu: 16 logical cores (Intel Family 6 Model 151) +[bench] workers: 4, duration: 5.0s +[bench] t= 1.0s hashes= 2,654,208 rate=2.64 MH/s +[bench] t= 2.0s hashes= 5,914,624 rate=2.93 MH/s +[bench] t= 3.0s hashes= 9,093,120 rate=3.01 MH/s +[bench] === result === +[bench] total hashes: 11,436,032 +[bench] wall time: 5.01s +[bench] hashrate: 2.28 MH/s +[bench] per-worker: 570.23 KH/s (workers: 4) +``` + **Валидация BTC-адреса** срабатывает локально перед подключением к пулу. Принимаются только mainnet-адреса: - `bc1q...` (P2WPKH, P2WSH) — bech32, BIP-173 - `bc1p...` (Taproot) — bech32m, BIP-350 @@ -162,7 +189,7 @@ hope-hash **Тесты:** ```bash -python -m unittest discover -s tests -v # 97 тестов +python -m unittest discover -s tests -v # 101 тест ``` **Prometheus-метрики, экспортируемые на `/metrics`:** diff --git a/ROADMAP.md b/ROADMAP.md index 435bfb1..7a49e3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -108,7 +108,7 @@ TUI и команды Telegram — отложены. - [x] **Demo-режим без подключения к пулу.** `hope-hash --demo [--demo-diff DIFF]`. Синтетический заголовок, low-diff target, multiprocessing-воркеры. Реализовано в `demo.py` (v0.3.0). - [ ] **«Гуманизированная» статистика.** «При твоём хешрейте средний шанс найти блок: раз в 47 миллиардов лет». Считается из текущего сетевого difficulty (получаем через bitcoin-core RPC или публичные API типа `mempool.space`). - [ ] **Lottery-визуализация.** Каждый хеш — точка на canvas. Цвет = первые 3 байта хеша. Просто красивая бесконечная анимация. Можно собрать на `pygame` или в браузере через WebSocket. -- [ ] **Бенчмарк-режим.** Прогоняет одну и ту же работу через все доступные backend-ы (pure Python, ctypes, C-extension, Rust, OpenCL) и печатает таблицу хешрейтов. Хороший способ показать, насколько Python медленный. +- [x] **Бенчмарк-режим (pure-Python).** `hope-hash --benchmark [--bench-duration SEC]`. Меряет хешрейт на синтетическом заголовке с target=0, репортит платформу/CPU/python и H/s. Реализовано в `bench.py` (v0.4.0). Baseline на Intel i7-12700H: ~570 KH/s/worker. Multi-backend сравнение (ctypes/C-extension/Rust/OpenCL) — отдельная задача после Уровня 2. - [ ] **Майнинг shitcoin-ов на той же базе.** Litecoin использует Scrypt, Dogecoin — тоже Scrypt. Если заменить хеш-функцию и пул, можно майнить с шансом найти блок раз в «всего» миллион лет вместо миллиарда. Кардинально меняется сложность кода. - [ ] **«Майнер на ладошке».** Запаковать в `pyinstaller` или `nuitka` в один бинарник с TUI — чтобы можно было кому-то отдать `.exe` и они «майнили» (с нулевым шансом, но прикольно). - [ ] **Лидерборд между друзьями.** Несколько твоих знакомых ставят майнер с разными worker-name на один и тот же BTC-адрес. Лидерборд показывает, кто из вас вносит больше хешрейта в общий пул. Если кто-то найдёт блок (хаха) — делите по вкладу. diff --git a/src/hope_hash/__init__.py b/src/hope_hash/__init__.py index 4ba4a3d..88c493c 100644 --- a/src/hope_hash/__init__.py +++ b/src/hope_hash/__init__.py @@ -1,5 +1,6 @@ """Hope-Hash — учебный solo BTC miner на чистом stdlib.""" +from .bench import BenchResult, run_benchmark from .block import build_merkle_root, difficulty_to_target, double_sha256, swap_words from .demo import run_demo from .metrics import Metrics, MetricsServer @@ -8,7 +9,7 @@ from .storage import ShareStore from .stratum import StratumClient -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = [ "double_sha256", "swap_words", @@ -17,6 +18,8 @@ "StratumClient", "mine", "run_demo", + "run_benchmark", + "BenchResult", "ShareStore", "Metrics", "MetricsServer", diff --git a/src/hope_hash/bench.py b/src/hope_hash/bench.py new file mode 100644 index 0000000..07f780e --- /dev/null +++ b/src/hope_hash/bench.py @@ -0,0 +1,142 @@ +"""Бенчмарк-режим: измеряет pure-Python SHA-256 хешрейт без сети. + +Используется как baseline перед оптимизациями (Rust/SIMD/GPU из ROADMAP +уровня 2-3): без числа «до» сравнивать с числом «после» нечестно. + +Принцип: те же multiprocessing-воркеры, что и реальный майнинг (parallel.py), +крутят SHA256d на синтетическом 76-байтовом header_base. Target ставим в 0, +чтобы ни один хеш не прошёл — воркеры просто хешируют, не отвлекаясь на +submit-логику. Через `duration_s` секунд останавливаем и считаем H/s. + +Запуск: + hope-hash --benchmark [--bench-duration 10] [--workers N] +""" + +from __future__ import annotations + +import multiprocessing +import platform +import struct +import sys +import time +from dataclasses import dataclass + +from ._logging import logger +from .parallel import start_pool, stop_pool + + +@dataclass +class BenchResult: + """Числа одного бенчмарк-прогона. Удобно для тестов и для JSON-экспорта.""" + duration_s: float + n_workers: int + total_hashes: int + hashrate_hps: float + + @property + def per_worker_hps(self) -> float: + return self.hashrate_hps / self.n_workers if self.n_workers > 0 else 0.0 + + +def _make_header_base() -> bytes: + """Синтетический 76-байтовый префикс header'а (всё кроме nonce). + + Структура совпадает с реальным майнингом, чтобы mid-state-оптимизация + в worker() работала идентично — иначе бенчмарк измерял бы не тот код. + """ + ntime_le = struct.pack(" str: + """H/s → KH/s → MH/s. Дублирует _format_rate из miner.py намеренно: + bench.py не должен зависеть от внутренностей mine().""" + if rate < 1000: + return f"{rate:.0f} H/s" + if rate < 1_000_000: + return f"{rate / 1000:.2f} KH/s" + return f"{rate / 1_000_000:.2f} MH/s" + + +def run_benchmark(duration_s: float = 10.0, n_workers: int = 1) -> BenchResult: + """Один прогон бенчмарка. Не делает сетевых вызовов и не находит шары. + + Параметры: + duration_s — сколько секунд хешировать. Меньше 1 нет смысла — + накладные расходы на spawn доминируют. + n_workers — число процессов-воркеров (как в реальном майнинге). + """ + n_workers = max(1, int(n_workers)) + duration_s = max(0.1, float(duration_s)) + header_base = _make_header_base() + target = 0 # никакой хеш не пройдёт → воркеры хешируют без выходов + extranonce2 = "00000000" + + logger.info(f"[bench] platform: {platform.platform()}") + logger.info( + f"[bench] python: {platform.python_version()} " + f"({sys.implementation.name})" + ) + logger.info( + f"[bench] cpu: {multiprocessing.cpu_count()} logical cores" + f"{f' ({platform.processor()})' if platform.processor() else ''}" + ) + logger.info(f"[bench] workers: {n_workers}, duration: {duration_s:.1f}s") + logger.info(f"[bench] running...") + + processes, found_queue, hashes_counter, mp_stop = start_pool( + n_workers, header_base, target, extranonce2, + ) + + start = time.perf_counter() + # Промежуточные сэмплы — ~5 точек за прогон, чтобы пользователь видел, + # что что-то происходит, но без лишнего шума в логе. + report_interval = max(1.0, duration_s / 5) + next_report = start + report_interval + try: + while time.perf_counter() - start < duration_s: + time.sleep(0.05) + now = time.perf_counter() + if now >= next_report: + with hashes_counter.get_lock(): + cur = hashes_counter.value + elapsed = now - start + rate = cur / elapsed if elapsed > 0 else 0.0 + logger.info( + f"[bench] t={elapsed:5.1f}s " + f"hashes={cur:>14,} rate={_format_rate(rate)}" + ) + next_report = now + report_interval + finally: + # Замеряем elapsed ДО stop_pool, чтобы не учесть время остановки. + # Воркеры коммитят последние батчи в hashes_counter в finally-блоке, + # поэтому финальное значение читаем уже после stop_pool(). + elapsed = time.perf_counter() - start + stop_pool(processes, found_queue, mp_stop) + + with hashes_counter.get_lock(): + total = hashes_counter.value + hashrate = total / elapsed if elapsed > 0 else 0.0 + + logger.info(f"[bench]") + logger.info(f"[bench] === result ===") + logger.info(f"[bench] total hashes: {total:>14,}") + logger.info(f"[bench] wall time: {elapsed:>14.2f}s") + logger.info(f"[bench] hashrate: {_format_rate(hashrate):>14}") + logger.info( + f"[bench] per-worker: " + f"{_format_rate(hashrate / n_workers):>14} (workers: {n_workers})" + ) + + return BenchResult( + duration_s=elapsed, + n_workers=n_workers, + total_hashes=total, + hashrate_hps=hashrate, + ) diff --git a/src/hope_hash/cli.py b/src/hope_hash/cli.py index 11611ab..d58df1f 100644 --- a/src/hope_hash/cli.py +++ b/src/hope_hash/cli.py @@ -69,6 +69,16 @@ def _parse_args() -> argparse.Namespace: metavar="DIFF", help="Сложность для demo-режима (по умолчанию: 0.001).", ) + parser.add_argument( + "--benchmark", action="store_true", + help="Запустить бенчмарк pure-Python хешрейта (без сети, без шар). " + "Полезно как baseline перед оптимизациями (C/Rust/SIMD/GPU).", + ) + parser.add_argument( + "--bench-duration", type=float, default=10.0, + metavar="SEC", + help="Длительность бенчмарка в секундах (по умолчанию: 10).", + ) return parser.parse_args() @@ -82,6 +92,15 @@ def main(): args = _parse_args() n_workers = max(1, args.workers) + if args.benchmark and args.demo: + print("error: --benchmark и --demo взаимоисключающи", file=sys.stderr) + sys.exit(2) + + if args.benchmark: + from .bench import run_benchmark + run_benchmark(duration_s=args.bench_duration, n_workers=n_workers) + return + if args.demo: from .demo import run_demo run_demo(n_workers=n_workers, diff=args.demo_diff) diff --git a/tests/test_bench.py b/tests/test_bench.py new file mode 100644 index 0000000..ed97faa --- /dev/null +++ b/tests/test_bench.py @@ -0,0 +1,72 @@ +"""Тесты для bench.py: проверяют форму результата и факт хеширования. + +Запускаем короткий бенчмарк (1с, 1 воркер) в spawn-подпроцессе — те же +причины, что в test_demo.py: multiprocessing на Windows плохо живёт +с pytest-нитями в том же процессе. +""" + +import multiprocessing as mp +import sys +import unittest + + +def _bench_worker(duration_s: float, n_workers: int, queue: "mp.Queue") -> None: + from hope_hash.bench import run_benchmark + + result = run_benchmark(duration_s=duration_s, n_workers=n_workers) + queue.put({ + "duration_s": result.duration_s, + "n_workers": result.n_workers, + "total_hashes": result.total_hashes, + "hashrate_hps": result.hashrate_hps, + "per_worker_hps": result.per_worker_hps, + }) + + +class TestRunBenchmark(unittest.TestCase): + + def test_short_run_produces_positive_hashrate(self): + ctx = mp.get_context("spawn") + q: "mp.Queue" = ctx.Queue() + # 1 секунда хватает: даже на CI runner'ах за 1с pure-Python успеет + # сделать десятки тысяч хешей. + p = ctx.Process(target=_bench_worker, args=(1.0, 1, q)) + p.start() + p.join(timeout=60) + self.assertFalse(p.is_alive(), "benchmark не завершился за 60 секунд") + self.assertEqual(p.exitcode, 0, "benchmark упал с ошибкой") + + result = q.get_nowait() + self.assertEqual(result["n_workers"], 1) + self.assertGreater(result["total_hashes"], 0, "ни одного хеша не посчитано") + self.assertGreater(result["hashrate_hps"], 0, "хешрейт нулевой") + # per_worker_hps == hashrate_hps при n_workers=1 + self.assertAlmostEqual( + result["per_worker_hps"], result["hashrate_hps"], delta=0.01 + ) + # Длительность должна быть близка к запрошенной (с допуском на overhead). + self.assertGreater(result["duration_s"], 0.5) + self.assertLess(result["duration_s"], 5.0) + + +class TestBenchResultDataclass(unittest.TestCase): + """Чистые свойства BenchResult — без multiprocessing.""" + + def test_per_worker_calculation(self): + from hope_hash.bench import BenchResult + + r = BenchResult( + duration_s=10.0, n_workers=4, total_hashes=4_000_000, hashrate_hps=400_000.0 + ) + self.assertEqual(r.per_worker_hps, 100_000.0) + + def test_per_worker_zero_workers(self): + from hope_hash.bench import BenchResult + + # Защита от деления на ноль: 0 воркеров → 0 H/s на воркера. + r = BenchResult(duration_s=1.0, n_workers=0, total_hashes=0, hashrate_hps=0.0) + self.assertEqual(r.per_worker_hps, 0.0) + + +if __name__ == "__main__": + unittest.main()