Codablecashの公開に備え、マイニング初心者として気になるのが「うちのマシンはどれくらい掘れるのか?」という部分です。 ハードウェアに追加投資すべきかの判断材料にもなるので、ローカルで簡易に実測してみました。

計測対象のマシン

  • 中古ミニPC(OptiPlex 3080 Micro)
  • CPU: Core i3-10105T(4コア8スレッド)
  • メモリ: 16GB
  • GPUなし(増設も不可)

CodablecashのPoW(マイニングの計算)は、AES・Salsa・SHA256・AstroBWTという複数のハッシュ計算を、ノンスに応じたランダムな順番でつなぐ設計です。GPUや専用チップ(ASIC)で殴っても効率が上がりにくい、CPU向けの作りとされています。

つまり、うちのノーマルスペックPCでもマイニングに参加できる嬉しい設計ということです。

その1: メモリがデュアルチャネルか確認する

PoWに含まれるAstroBWTは、CPUの計算能力よりも「メモリからどれだけ速くデータを出し入れできるか」が性能を左右しやすいアルゴリズムです。メモリが1枚挿しか、2枚でデュアルチャネル動作かで帯域は大きく変わるので、まず現状を確認します。

$ sudo dmidecode -t memory | grep -E 'Size|Speed|Locator|Type:'
(関係する行のみ抜粋)
    Size: 8 GB
    Locator: DIMM1
    Type: DDR4
    Speed: 3200 MT/s
    Configured Memory Speed: 2666 MT/s
    Size: 8 GB
    Locator: DIMM2
    Type: DDR4
    Speed: 3200 MT/s
    Configured Memory Speed: 2666 MT/s

8GBが2枚、両スロットに1枚ずつ。既にデュアルチャネルでした

なお、メモリ自体は3200MT/s対応品なのに2666MT/sで動いていますが、これはCPU側の対応上限が2666MT/sのためで、仕様通りの動作です。

その2: 本物のマイニングコードでハッシュレートを測る

暗号資産(仮想通貨)のマイニングの実力は「1秒間に何回ハッシュ計算というくじを引けるか」(ハッシュレート、単位H/s)で決まります。せっかくソースコードが公開されているので、Codablecash本体のマイニング処理そのものを回して測ってみます。

ソースを読むと、マイニングのループは意訳するとこうなっています。

  1. ランダムなノンス(くじ引き用の自由に回してよいダイヤル)を作る
  2. ブロックの情報とノンスから、ハッシュ(データの指紋のようなもの)を計算する
  3. ハッシュが難易度の条件を満たしていれば当たり。外れなら1に戻る

この「1セット」を探してみると同梱テストの中にほぼそのままの形でありましたsrc_test/blockchain/pow/test_pow_random_hash.cppという、くじを1回だけ引いてみるテストです(呼んでいるのは本体のマイニング処理と同じ関数でした)。

私はC++を書けませんが、1回引けるなら、あとはループで回して時計で数えるだけのはず。このテストコードを見よう見まねで、改造したのがこちらです。

/*
 * bench_pow.cpp — Codablecashハッシュレート計測
 * 同梱テスト(src_test/blockchain/pow/test_pow_random_hash.cpp)を参考に、
 * 本物のマイニング1回分(createRandomNonce → calcResult)をループで回して数えるだけ。
 * 使い方: ./bench_pow [秒数=30]
 */
#include <chrono>
#include <cstdio>
#include <cstdlib>

#include "pow/PoWNonce.h"
#include "pow/PoWNonceResult.h"
#include "bc_block/BlockHeaderId.h"
#include "bc_block/BlockMerkleRoot.h"
#include "base_timestamp/SystemTimestamp.h"
#include "base/StackRelease.h"

using namespace codablecash;

int main(int argc, char** argv) {
	int seconds = argc > 1 ? atoi(argv[1]) : 30;

	char bin[32];
	for (int i = 0; i < 32; ++i) {
		bin[i] = (char)(i * 7 + 3); // ダミーの前ブロック指紋とマークルルート
	}
	BlockHeaderId bid(bin, 32);
	BlockMerkleRoot root(bin, 32);
	SystemTimestamp tm;

	auto start = std::chrono::steady_clock::now();
	long count = 0;
	double secs = 0;
	while (secs < seconds) {
		PoWNonce* n = PoWNonce::createRandomNonce(); __STP(n);
		PoWNonceResult* r = n->calcResult(&bid, &root, &tm); __STP(r);
		++count;
		secs = std::chrono::duration<double>(
				std::chrono::steady_clock::now() - start).count();
	}
	printf("%.1f秒で%ld回 → %.2f H/s\n", secs, count, count / secs);
	return 0;
}

こちらを実行するにあたり、前提条件は以下3つあります。

  • 公式リポジトリをcloneして、ビルドとテスト(./sh/maketest.sh)が済んでいること
  • 上のbench_pow.cppをリポジトリ直下に保存してあること
  • コマンドはリポジトリ直下で実行すること

なお、作業はすべて隔離環境(Podmanコンテナ)の中で行っています。以下のプロンプトbuilder$がその中です。

builder$ g++ --coverage -o /tmp/bench_pow bench_pow.cpp \
    -Isrc_blockchain -Isrc_db -Isrc -Isrc_ext \
    target/libcodablecashlib.a target/src_ext/libextlib.a -lpthread -lgmp

-Iはヘッダの場所、最後の2つの.aが、テストのときにビルドされたCodablecash本体(部品の詰め合わせ)です。--coverageは、テスト用ビルドの部品を借りる都合で必要になるおまじないです(付けないと組み立ての最後で怒られます)。

まずは、この計測プログラムを1個だけ起動して30秒測ります。プログラム1個はCPUのコアをおおよそ1個ぶん使って、ひたすらくじを引き続けます。

builder$ /tmp/bench_pow 30
30.0秒で303回 → 10.09 H/s

約10H/s。うちのCPUは4コア8スレッドなので、次は同じプログラムを同時に4個、8個起動して、全員の合計で見てみます。

builder$ for i in 1 2 3 4; do /tmp/bench_pow 30 & done; wait
30.0秒で296回 → 9.86 H/s
30.0秒で291回 → 9.69 H/s
30.1秒で292回 → 9.72 H/s
30.1秒で292回 → 9.71 H/s

builder$ for i in 1 2 3 4 5 6 7 8; do /tmp/bench_pow 30 & done; wait
30.0秒で160回 → 5.33 H/s
30.0秒で161回 → 5.37 H/s
30.0秒で181回 → 6.03 H/s
30.0秒で180回 → 5.99 H/s
30.1秒で185回 → 6.16 H/s
30.1秒で180回 → 5.99 H/s
30.1秒で185回 → 6.15 H/s
30.1秒で187回 → 6.21 H/s

結果

集計はただの足し算です。たとえば8個同時なら、8人の回数の合計160+161+181+180+185+180+185+187=1,419回を30秒で割って、約47.2H/sになります。

条件 合算ハッシュレート 1個比
1個 10.1H/s 1.0倍
4個同時 39.0H/s 3.9倍
8個同時(フル負荷) 47.2H/s 4.7倍

考察: 数字が教えてくれた2つのこと

その1: 最適化しても、期待ほど速くならない

ここでいう「最適化」は、ソースコードを機械語に翻訳(コンパイル)するときの推敲のことです。最適化なしだと、翻訳者は書いてある順番どおりに律儀に訳します。最適化ありだと、意味を変えない範囲で「この計算はループの外で1回やれば済む」「この値はメモリに置かず手元で持ち回そう」と書き直しながら訳すので、同じ計算結果でも実行速度が数倍変わることがあります。

今回使ったテスト用ビルドは、この推敲なし+カバレッジ計測入りという、少しのハンデがついた状態です。気になったので、推敲を全開にしたビルドを別に作って同じ計測をしてみたのですが、10.09→11.36H/sと、1割ほどの伸びにとどまりました

「CPUの計算や段取りの速さではなく、メモリとの往復(帯域)が上限を決めている」、つまり、速い頭脳を持ってきても待ち時間が増えるだけ、という数字になりました。

その2: 並べる数を増やしても頭打ち

4個(物理コアぶん)までは素直に3.9倍。ところが、ハイパースレッディングの領域に入る8個にしても、そこからは+21%しか伸びません。1個あたりの速度も約10→6H/sに落ちていて、物理4コアを超えたあたりから、メモリ帯域の取り合いが始まっているようです。

判断: CPU換装は見送り

同世代の中古CPU(6コアや8コアのT付きi5/i7)への買い替えも検討項目でした。しかし上の2つの結果から、見送りと判断しました。コア数を1.5〜2倍にしても、メモリ帯域が同じままでは伸びはコア数比を大きく下回りそうだからです。

電気代の見積もり

マイニングは24時間回してこそなので、電気代も見ておきます。CPUのTDPは35W。フル負荷時のシステム全体では50W前後と推定して、

  • 50W×24時間×30日=36kWh/月
  • 電気代にして月1,300円前後(35円/kWhで計算)

といったところです。このマシンはもともと24時間稼働させているので、厳密な増分はアイドル時との差でもう少し小さいはずですが、単純計算だとこのくらいになります。

まとめ

  • 手持ちミニPC(i3-10105T)のCodablecashマイニング力は、フル負荷で47.2H/s
  • 4コアまでは素直に伸びる
  • 8スレッドではメモリ帯域がボトルネック?
  • 最適化しても1割しか伸びない=メモリ帯域律速。ASIC耐性設計を数字で実感

おまけ: 今回の用語(厨房編)

実はあまりPC用語は得意ではなく、混乱してしまうので、自分が後から読み返すためのメモです。PC全体を厨房と見立てます。

先にまとめておくと、うちの厨房は、料理人4人(+両手)が、2本の通路で冷蔵庫と往復しながら、1秒に47.2回くじを引ける。料理人を増やしたり手を速くしたりしても通路が渋滞するだけなので、増強するなら厨房ごともう1軒のほうが良い。

計算する側(料理人チーム)

  • CPU: 料理人チームそのもの。プログラムの命令を実行する部品
  • コア: チームの料理人の人数(物理的に独立した計算係)。うちは4人
  • スレッド(ハイパースレッディング): 料理人1人が両手で2つの作業を器用に回す仕組み。4人×両手で見かけ8人になるが、体は4つなので8人分は働けない(実測でも5〜8個目は+21%どまりだった)
  • クロック(GHz): 料理人の手の速さ。1秒間に何回作業できるか

覚える側(食材置き場)

  • メモリ(RAM): 冷蔵庫。いま調理中の食材=実行中のプログラムとデータの置き場。速いが、電源を切ると中身が消える
  • メモリ容量(16GBなど): 冷蔵庫の大きさ。マイニングのくじ1回は数MBしか使わないので、今回はほぼ無関係だった
  • メモリ帯域(43GB/sなど): 冷蔵庫とまな板の間の通路の運搬能力。1秒間に何GB運べるか。今回の主役。マイニング速度の上限を決めていたのはこれ
  • デュアルチャネル: メモリ2枚挿しで通路を2本にする仕組み。帯域がほぼ2倍になる

その他の脇役

  • 最適化: コンパイル(翻訳)時の推敲。段取りを組み替えて速い機械語にすること
  • ハッシュレート(H/s): 1秒間に引けるくじの回数。マイニング力の単位
  • TDP(35Wなど): そのCPUが設計上想定する消費電力・発熱の目安