PowerShellの$N/$nバグで素数0個、修正後はpwshが2.5倍速
この記事の見出し
2026-07-30 の素数カウントは、バグ版だと pwsh 7.6.4 が「0 3」、powershell.exe 5.1 が「0 4」だった。
測定日は2026年7月30日だった。
素数が0個で、実行時間が3msか4ms。200000未満の素数を数えるつもりだったので、結果の時点で明らかに動いていなかった。最初に見たときは PowerShell のループが速いというより、どこかで計測コードを壊した感じが強かった(この時点で止まって確認できたのは助かった)。
原因は単純で、PowerShell の変数名が大文字小文字を区別しないことだったのに、上限として $N を置き、ループ変数として $n を使っていた(C や Python の癖がそのまま出た)。C や Python の感覚では別物に見えるが、PowerShell では同じ変数として扱われる。
$n=2 を代入した瞬間に、上限の $N も2へ変わった。したがって最初の判定で $n -lt $N は偽になり、ループは1回も回らずに終わる。気づくまで少し時間がかかってしまい、結果が速すぎるときに先に喜ぶのではなく、出力の正しさを見るべきだった(この順番を逆にするとベンチマークは簡単に壊れる)。
壊れたコードと直したコード
最初に書いた形は、だいたいこうだった。
$N=200000
$sw=[Diagnostics.Stopwatch]::StartNew()
$c=0
for($n=2;$n -lt $N;$n++){
# trial division で素数判定
}
Write-Output "$c $($sw.ElapsedMilliseconds)"
PowerShell では $N と $n が衝突する。そこで修正版では上限を $limit に変えた。アルゴリズム自体を変えたわけではなく、変数名だけの修正だった。
$limit=200000
$sw=[Diagnostics.Stopwatch]::StartNew()
$c=0
for($n=2;$n -lt $limit;$n++){
# trial division で素数判定
}
Write-Output "$c $($sw.ElapsedMilliseconds)"
出力の左側は素数の個数、右側はスクリプト内の Stopwatch が返したミリ秒だ。外側から見た wall time も別に取っているが、ここでは主にスクリプト内の経過時間を見た。プロセス起動の話は別に測ったことがあるので、今回はループが実際に走ったあとの実行速度だけに絞っている(起動込みにすると、別の話が混ざる)。
この環境では Windows 11 Pro ARM64 の Surface Pro 11th Edition で測った。実際に比べた対象は pwsh 7.6.4 と Windows PowerShell 5.1 の2つ。どちらも同じ処理を実行している。
0個の速さは速さではない
バグ版と修正版を分けて置くことにしたのは、速く見える壊れた結果と、正しい答えが出た後の結果を同じ表の中で混ぜると、どこからが比較可能なのか分かりにくくなるからだ(今回の失敗そのものを残すためでもある)。数値は実測 JSON に入っていたものだけを使っている。
| 種別 | 実行環境 | wall time | 出力 |
|---|---|---|---|
| バグ版 | pwsh 7.6.4 | 1062ms | 0 3 |
| バグ版 | powershell.exe 5.1 | 382ms | 0 4 |
| 修正版 | pwsh 7.6.4 | 6221ms / 5857ms | 17984 5171 / 17984 5232 |
| 修正版 | powershell.exe 5.1 | 13527ms / 15866ms | 17984 13051 / 17984 15077 |
バグ版の3msと4msは、処理が速いのではなく処理していないだけだった。外側の wall time は pwsh が1062ms、powershell.exe が382msなので、プロセスを立ち上げてスクリプトを流す時間はそれなりにかかっている。それでも肝心のループは0回で終わっていて、外側の起動時間だけを眺めると何かが動いたように見えるのに、スクリプト本体は素数判定へ入る前に抜けていた(このズレがいちばん紛らわしい)。
修正版ではどちらも素数17984個になり、ここでようやく比較できる(答えが揃ってから速度を見る、という当たり前の順番)。pwsh は5171msと5232ms、powershell.exe は13051msと15077msだった。同じスクリプトなのに、実行に入ると pwsh のほうが2.5〜2.9倍速い(ここで起動時間の印象を引きずると読み違える)。
これは想定外で、以前に起動だけを測ったときは Windows PowerShell 5.1 のほうが速かったので、短い自動化なら powershell.exe に寄せる理由があると思っていた。けれど今回のようにループをしっかり回す処理では、起動の差より実行中の差が大きくなる。
何にハマったか
一番危なかったのは、時間だけを見ればバグ版が圧倒的に速く見えることだった。3msや4msという数字だけを拾うと、PowerShell が素数カウントを一瞬で終えたように読めてしまう。
実際には答えが0個で、なぜ速いのかではなく、なぜ答えが消えたのかを見るべき場面だった(ここを見落としていたら、完全に間違えた記事を書いていたはずだ)。ベンチマークで「速すぎる」値が出たときは、まず出力値そのものを疑う。今回の教訓はそこに尽きる、という話。
PowerShell の変数名が case-insensitive であることも、知識としては見たことがあった気がする。手癖で $N と $n を並べた瞬間には思い出せなかった。エラーも警告も出ないので、失敗の見え方が静かだ(静かすぎて、時間だけ見ていると気づきにくい)。
$N と $n の衝突は、シェルスクリプトの短い変数名ほど踏みやすい。ベンチマークでは N が上限、n が現在値という書き方をしがちなので、PowerShell では避けたほうがいい。$limit のように意味のある名前にしておくと、この種類のバグはかなり減らせる。
ばらつきもまだ残る
修正版の powershell.exe は、13051msと15077msでかなり振れた。差は15%ほどだ。2回しか測っていないので、ここから信頼区間のような話はできなかった。
pwsh 側は5171msと5232msで近かったが、これも2回だけの記録である。たまたま揃っただけかもしれない。今回言えるのは、少なくともこの2回では pwsh が明確に速かった、という範囲に留めるのが安全だ。
個人的には、PowerShell 7 は起動が重いという印象を持っていた。今回の結果を見ると、その印象を実行速度まで広げるのは雑だった。短いワンショットなら powershell.exe、長めに計算するなら pwsh、という切り分けのほうが手元の実測には合っている。
使い分けをどう変えるか
今回の結論は条件つきにした。最初のコードはバグで動かなかったが、変数名を直せば素数17984個まで到達している。計測としてはやり直しになったものの、修正版から見えたのは実行速度の差だった。
今後、PowerShell で小さなベンチマークを書くときは、まず出力値を固定の期待値と照合する。速さの表だけを先に作るのは避ける。0個や0件のような結果が出ていないかを見てから、時間の比較に進む。
変数名も短くしすぎない。$N と $n はPowerShellでは同じものなので、上限は $limit、現在値は $candidate のように分ける。少し長くても、測定コードが黙って壊れるよりましだ。
もう少し確かめるなら、次は10回以上まわして中央値を見てみる。powershell.exe の15%差が単なるばらつきなのか、Windows PowerShell 5.1 側で出やすい揺れなのかは、今回の2回だけではまだ決めずにおく。