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()