ネットワークドライブ上の GB オーダーのファイルに対して、複数のハッシュアルゴリズムでファイルハッシュを計算する必要に駆られていた。
「そんなシチュエーションが本当にあるのか?」と思うかも知れないが、あったのだから仕方ない。
最初は、 PowerShell を使って 愚直に Get-FileHash を 2回 計算していた。
しかし当たり前だが、 2回ファイルをダウンロードすることになるこの方法は遅くて仕方ない。
ということで、 C# でコンパイルしたコード上で、 一度メモリに読み込ませてから複数のハッシュアルゴリズムで計算できるようなコードを作成した。
#Requires -Version 5.0 | |
[CmdletBinding(DefaultParameterSetName = "Path")] | |
param( | |
[Parameter(Mandatory, ParameterSetName="Path", Position = 0)] | |
[System.String[]] | |
$Path, | |
[Parameter(Mandatory, ParameterSetName="LiteralPath", ValueFromPipelineByPropertyName = $true)] | |
[Alias("PSPath")] | |
[System.String[]] | |
$LiteralPath, | |
[Parameter(Mandatory, ParameterSetName="Stream")] | |
[System.IO.Stream] | |
$InputStream, | |
[ValidateSet("SHA1", "SHA256", "SHA384", "SHA512", "MD5")] | |
[ValidateNotNullOrEmpty()] | |
[System.String[]] | |
$Algorithms=@("SHA256"), | |
[ValidateRange(1, 2GB)] | |
[int] | |
$BufferSize=8MB | |
); | |
begin { | |
Set-StrictMode -Version Latest; | |
trap { break; } | |
$algorithmTypeInfos = @(); | |
$algorithmTypeInfos += $Algorithms | ForEach-Object { | |
$algorithmType = "System.Security.Cryptography.$_" -as [Type]; | |
if (!$algorithmType) { | |
throw "Not Supported: $_"; | |
} | |
Write-Output @{Name = $_; Type = $algorithmType }; | |
}; | |
$hashCoreType = Add-Type -Language CSharp -PassThru -TypeDefinition @" | |
using System.Collections.Generic; | |
using System.IO; | |
using System.Linq; | |
using System.Security.Cryptography; | |
using System.Threading.Tasks; | |
public static class HashCore { | |
public static void Hash(Stream inputStream, IEnumerable<HashAlgorithm> hashers, int bufferSize) { | |
var bin1 = new byte[bufferSize]; | |
var bin2 = new byte[bufferSize]; | |
byte[] bint; | |
int read = 0; | |
var hasherArray = hashers.ToArray(); | |
var streams = new CryptoStream[hasherArray.Length]; | |
var waitTasks = new Task[hasherArray.Length + 1]; | |
try { | |
for (var i = 0; i < hasherArray.Length; i++) { | |
streams[i] = new CryptoStream(Stream.Null, hasherArray[i], CryptoStreamMode.Write); | |
} | |
do { | |
var readTask = inputStream.ReadAsync(bin1, 0, bufferSize); | |
for (var i = 0; i < hasherArray.Length; i++) { | |
waitTasks[i] = streams[i].WriteAsync(bin2, 0, read); | |
} | |
waitTasks[hasherArray.Length] = readTask; | |
Task.WaitAll(waitTasks); | |
read = readTask.Result; | |
bint = bin1; | |
bin1 = bin2; | |
bin2 = bint; | |
} while (read > 0); | |
for (var i = 0; i < hasherArray.Length; i++) { | |
streams[i].FlushFinalBlock(); | |
} | |
} finally { | |
foreach (var stream in streams) { | |
if (stream != null) { | |
stream.Dispose(); | |
} | |
} | |
} | |
} | |
} | |
"@; | |
function Get-StreamHash ([System.IO.Stream]$InputStream) { | |
$hashers = New-Object System.Security.Cryptography.HashAlgorithm[]($algorithmTypeInfos.Length); | |
try { | |
for ($i = 0; $i -lt $algorithmTypeInfos.Length; $i++) { | |
$hashers[$i] = $algorithmTypeInfos[$i].Type::Create(); | |
} | |
$hashCoreType::Hash($InputStream, $hashers, $BufferSize); | |
for ($i = 0; $i -lt $algorithmTypeInfos.Length; $i++) { | |
[ordered]@{Algorithm = $algorithmTypeInfos[$i].Name; Hash = [System.BitConverter]::ToString([byte[]]$hashers[$i].Hash) -replace '-',''}; | |
} | |
} finally { | |
foreach ($hasher in $hashers) { | |
if ($null -ne $hasher) { | |
$hasher.Dispose(); | |
} | |
} | |
} | |
} | |
} | |
process { | |
$results = @(); | |
if ($PSCmdlet.ParameterSetName -eq 'Stream') { | |
$results += Get-StreamHash -InputStream $InputStream; | |
} else { | |
$filePaths = @(); | |
if ($PSCmdlet.ParameterSetName -eq 'Path') { | |
$filePaths += Resolve-Path $Path | ForEach-Object -MemberName ProviderPath; | |
} | |
if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { | |
$filePaths += Resolve-Path -LiteralPath $LiteralPath | ForEach-Object -MemberName ProviderPath; | |
} | |
foreach ($filepath in $filePaths) { | |
$stream = [System.IO.File]::OpenRead($filePath); | |
try { | |
$currentResults = Get-StreamHash -InputStream $stream; | |
foreach ($result in $currentResults) { | |
$result['Path'] = $filePath; | |
} | |
$results += $currentResults; | |
} finally { | |
$stream.Dispose(); | |
} | |
} | |
} | |
foreach ($result in $results) { | |
$retVal = [PSCustomObject]$result; | |
$retVal.PSObject.TypeNames.Insert(0, "Microsoft.Powershell.Utility.FileHash"); | |
$retVal; | |
} | |
} |
バッファーサイズを 80KiB 程度とすると、 .NET の仕様で 85KB を境にメモリの扱いが変わる (LOH と呼ばれる特別なヒープに移動する) ことから、 このサイズを超えると一般的に動作速度が下がると言われている。
しかし、今回のコードの場合バッファーが小さいと、 Task 周りの処理がボトルネックになってしまうので、 8MiB くらいのサイズとしている。
80KiB としているのは、 .NET mscorlib の System.IO.Stream.CopyTo
メソッドなどでもおなじみなのだが、これが必ずしも正解とは限らないわけだな。
パフォーマンス
800Mbps 位でシーケンシャルリードできるリモート上の 4GB のファイルを、 4回 ずつ計測した平均を計測した。
Get-FileHash
の 2種類目 以外はクライアントキャッシュが効かないように注意し、 計測の各回でハッシュを計算するファイルはそれぞれ別のファイルで、中身はランダムバイナリとした。
Get-FileHash
については、 計測の各回では同じファイルに対して -Algorithm
パラメータを変えて複数回連続で呼び出した。
計算したハッシュの種類 | Get-FileHash |
上記の改良スクリプト |
---|---|---|
1種類 (SHA1) | 48.8s | 43.3s |
2種類 (MD5, SHA1) | 60.4s | 44.2s |
3種類 (MD5, SHA1, SHA256) | 83.3s | 48.2s |
ん? 計算したハッシュが 1種類 の場合でも、改良スクリプトの方が早いぞ?
うわっ… HashAlgorithm.ComputeHash
の実行速度、低すぎ…?
PowerShell v5 相当
PowerShell v6 相当
上記のように、 Get-FileHash
は、内部的に HashAlgorithm.ComputeHash
でハッシュ計算を実行している。
で、その HashAlgorithm.ComputeHash
がどうなっているかというと、 4KiB 毎に ファイルの読み込みと、 ハッシュストリームへの書き込みを 同期的に 行っている。
ファイルの読み込みも、 (使うアルゴリズムにもよるけど) ハッシュの計算も、 どちらもコストがかかるので、同期的にやってたらそりゃ遅いわ。