前提
複数の日付が混在する大規模CSVファイルを日付単位で分割する処理が必要になった。
CSVは10万行を超えることがあり、処理速度が重要だった。
利用できるのはPowerShellのみだったため、PowerShell内で最速のCSVファイル読み込み方法を検証した。
(例)
file.csv
| Col1 | Col2 | Col3 | … |
|---|---|---|---|
| 202404 | xxx | 123 | … |
| 202405 | yyy | 456 | … |
| 202404 | zzz | 789 | … |
| … | … | … | … |
↓
file_202404.csv
| Col1 | Col2 | Col3 | … |
|---|---|---|---|
| 202404 | xxx | 123 | … |
| 202404 | zzz | 798 | … |
| … | … | … | … |
file_202405.csv
| Col1 | Col2 | Col3 | … |
|---|---|---|---|
| 202405 | yyy | 456 | … |
| … | … | … | … |
処理内容
今回のPowerShellスクリプトには以下の4つの処理が含まれる。
1.ファイル読み込み
2.ユニークな分割対象値
3.対象データのフィルタリング
4.ファイル書き込み
処理対象
レコード数・カラム数がバラバラな計40個のCSVファイルに対して処理を行う。
ファイルの合計サイズは約1GBである。
端末スペック・PowerShellバージョン
実施端末やPowerShellのバージョンは以下の通りである。
| 名称 | 情報 |
|---|---|
| OS | Windows 10 pro |
| CPU | Inter Xeon |
| メモリ | 32GB |
| PowerShell | 5.1 |
実施手法
A : 標準コマンドレットを使用する
PowerShellの標準コマンドレットを利用した処理
参考
PowerShell CSVの特定の列でフィルターして抽出/分割する
コード全体
# 1.csvファイルの読み込み
$csv = Import-Csv -Path file.csv -Encoding UTF8
# 2.ユニーク配列の取得
$unique_vals = $csv | Select-Object -Property "Col1" -Unique
# 3.対象データのフィルタリング
foreach($val in $unique_vals."Col1") {
# 4.ファイル出力
$output_file_name = "file_" + $val + ".csv"
$csv | Where-Object { $_.Col1 -eq $val} | Export-Csv -Path $output_file_name
}
# 5.""の削除
$files = Get-ChildItem ./*
foreach($file in $files) {
(Get-Content $file) | Foreach-Object { $_ -replace '"', '' } | Set-Content $file
}
1.ファイル読み込み
$csv = Import-Csv -Path file.csv -Encoding UTF8
(※-Path, -Encodingは適宜読み替え)
PowerShellの標準コマンドレットであるImport-CsvでCsvファイルを読み込む。
2.ユニークな分割対象値の取得
$unique_vals = $csv | Select-Object -Property "Col1" -Unique
(※-Propertyは適宜読み替え)
Import-Csvで読み込んだ$csvはPSObjectとして読み込まれる。
Select-Object -Uniqueで対象カラムのユニーク配列を取得できる。
3.対象データのフィルタリング
foreach($val in $unique_$vals) {
$output_data = $csv | Where-Object { $_.Col1 -eq $val }
}
Where-Objectで対象カラムが合致するレコードを抽出する
Where-ObjectはWhere()に置換可能だが、戻り値がPSObjectではなくCollectionとなる。
# 戻り値はCollectionになる。
$output_data = ($csv).Where({$_.Col1 -eq $val})
4.ファイル書き込み
#出力時のファイル名を指定
$output_file_name = "file_" + $val + ".csv"
$output_data | Export-Csv -Path $output_file_name
Export-Csvでcsvファイルを出力する。
$output_dataはPSObjectなので、パイプラインとして繋げることができる。
$csv | Where-Object { $_.Col1 -eq $val} | Export-Csv -Path $output_file_name
ただし、PowerShell 6未満ではExport-Csvで出力されたファイルには自動で""が付与されているため取り除く必要がある。
# Export-Csvで出力したCSVファイルの""を取り除く
(Get-Content "file.csv") | ForEach-Object { $_ -replace '"' , '' } | Set-Content "file.csv"
出力したファイルを一度読み込み""を置換する。
5.実行結果
40個のファイルを処理するのにかかった時間は58分54秒だった。
Import-Csvは1ファイルあたり大体5秒程度だったが、100,000行を超えるファイルだと90秒程度かかり、1,000,000行超だと7分程度読み込みにかかった。
Select-Objectによるユニーク配列取得についても、レコード数が多くなると配列作成に10秒程度かかる結果となった。
最も時間がかかった処理はデータの絞り込みとファイル出力であり、1,000,000行超えのファイルでは全データ出力に20分程度かかっていた。
おそらくパイプライン処理を行っているため、巨大CSVファイルになると全てのデータをメモリに読み込んだ上で抽出処理や出力処理を行うことになり、処理に時間がかかったのだと推察される。
一度出力したファイルの""を削除する工程も10分程度かかっており、二度手間感が拭えない。
B : 標準コマンドレットと.NETを組み合わせる
.NETのSystem.IO.StreamReader 、System.IO.StreamWriter を利用する。
参考
コード全体
# ファイル読み込み時の文字コードを設定
$read_encoder = [System.Text.Encoding]::GetEncoding("UTF-8")
# ファイル読み込みオブジェクトであるStreamReaderを生成
$reader = New-Object System.IO.StreamReader("file.csv", $enc_from, $false, 20480000)
# csvファイルを読み込み、テーブル形式に変換
$data = $reader.ReadToEnd() | ConvertFrom-Csv
# ユニークな配列を格納するハッシュセットを生成する
$unique_vals = [System.Collections.Generic.HashSet[string]]::new()
# ハッシュセットに対象カラムを追加する
$unique_vals.UnionWith($data."Col1")
foreach($val in $unique_vals) {
# ユニークな値ごとにデータをフィルタリングする
$output_data = $data.Where({ $_."Col1" -eq $val}) | ConvertTo-Csv | Out-String
# 書き込みファイルの文字コード
$enc_to = [System.Text.Encoding]::GetEncoding("UTF-8")
# 出力時のファイル名を設定
$output_file_name = "file_" + $val + ".csv"
# ファイル書き込みオブジェクトであるStreamWriterを生成
$writer = New-Object System.IO.StreamWriter("c:\temp\test_output.txt", $false, $enc_to, 20480000)
# ""を除外してファイルに書き込み
$writer.Write($output_data.Replace('"',''))
# StreamWriterオブジェクトを閉じる
$writer.Close()
$writer = $null
}
# StreamReaderオブジェクトを閉じる
$reader.Close()
$reader = $null
1.ファイル読み込み
# ファイル読み込み時の文字コードを設定
$read_encoder = [System.Text.Encoding]::GetEncoding("UTF-8")
# ファイル読み込みオブジェクトであるStreamReaderを生成
$reader = New-Object System.IO.StreamReader("file.csv", $enc_from, $false, 20480000)
# csvファイルを読み込み、テーブル形式に変換
$data = $reader.ReadToEnd() | ConvertFrom-Csv
# StreamReaderオブジェクトを閉じる
$reader.Close()
$reader = $null
Import-Csv と異なり、.ReadToEnd() メソッドはstring を返す。
ConvertFrom-Csv コマンドレットを使用し、PSObjectに変換する。
2.ユニークな分割対象値
# ユニークな配列を格納するハッシュセットを生成する
$unique_vals = [System.Collections.Generic.HashSet[string]]::new()
# ハッシュセットに対象カラムを追加する
$unique_vals.UnionWith($data."Col1")
ユニークな要素をもつオブジェクトを生成し、そこにデータを追加する方式を採用した。
HashSet<T> はPythonの set{} と類似した配列の1つであり、重複する値を無視する。
先にオブジェクトを生成し、後から配列を追加する。
参考
3.対象データのフィルタリング
foreach($val in $unique_vals) {
$output_data = $data.Where({ $_."Col1" -eq $val}) | ConvertTo-Csv | Out-String
}
データ抽出は.Where() メソッドを採用した。
ある記事によるとWhere-Object と.Where() では.Where() のほうが3倍程度早く処理される。
抽出されたデータをConvertTo-Csv でファイルの出力形式に、Out-String で文字列に変換する。
参考
PowerShellのWhere-Object句のパフォーマンスが遅いので対応例
4.ファイル書き込み
foreach($val in $unique_vals) {
# 書き込みファイルの文字コード
$enc_to = [System.Text.Encoding]::GetEncoding("UTF-8")
# 出力時のファイル名を設定
$output_file_name = "file_" + $val + ".csv"
# ファイル書き込みオブジェクトであるStreamWriterを生成
$writer = New-Object System.IO.StreamWriter("c:\temp\test_output.txt", $false, $enc_to, 20480000)
# ""を除外してファイルに書き込み
$writer.Write($output_data.Replace('"',''))
$writer.Close()
$writer = $null
}
System.IO.StreamWriter オブジェクトを生成してファイルに書き込む。
.Replace()メソッドで出力データに付与された "" を除外する。
5.実行結果
40個のファイルを処理するのにかかった時間は34分26秒だった。
Import-Csv/Export-Csvを利用した方法と比べて処理時間が短くなったものの、依然として巨大ファイルの入出力には3分程度かかるため、処理速度が格段に上がったわけではない。
特に読み込みについてはさほど変わらない結果となった。
これは StreamReader.ReadToEnd() でファイルを一気に読みこむものの、 ConvertFrom-Csv に変換する際にパイプライン処理を経ているため、メモリ上での処理に時間がかかっているのだと考えられる。
また、データの絞り込みについてもWhere-Objectと比べると早くなったものの、ファイルの大きさによっては絞り込みに2分程度かかることがあった。
一方でユニーク配列を取得する方法についてはSelect-Object -Uniqueと比べて格段に速くなり、どんなにレコード数が多くても、3秒程度で配列を返してくれるようになった。
また、ファイル出力についても"" を書き込みと同時に除外するため、処理効率が向上した。
読み込みと絞り込みを工夫できればさらに処理速度を上げることができそうな結果となった。
C : NETを組み合わせる
ConvertFrom-Csvではなく、DataTableオブジェクトに変換した。
参考
DataTable in PowerShell for crazy fast filters
コード全体
# ファイル読み込み時の文字コードを設定
$read_encoder = [System.Text.Encoding]::GetEncoding("UTF-8")
# ファイル読み込みオブジェクトであるStreamReaderを生成
$reader = New-Object System.IO.StreamReader("file.csv", $enc_from, $false, 20480000)
# 行数カウンターを定義
$row = 0
$table = New-Object System.Data.DataTable
# 最後の1行が読み込まれるまで処理を繰り返す
while(!($reader.EndOfStream)) {
# ファイルを1行ずつ読み込み、","で分離する
$data = ($reader.ReadLine()).Split(",")
# 行カウンターを加算する
$row += 1
switch( $row ) {
# 行カウンターが1の場合 → ヘッダー
1 {
switch( $data ) {
default {
# DataTableのカラムを追加する
$table.Columns.Add($_)
}
}
}
# 行カウンターが1以外の場合 → ボディ
default {
# DataTableのレコードを追加する
$table.Rows.Add($data)
}
}
}
# ユニークな配列を格納するハッシュセットを生成する
$unique_vals = [System.Collections.Generic.HashSet[string]]::new()
# ハッシュセットに対象カラムを追加する
$unique_vals.UnionWith($table."Col1")
foreach($val in $unique_vals) {
# 抽出条件を定義
$filter = "Col1 = $($val)"
# データを抽出
$output_data = $table.Select($filter)
# 抽出データをファイル出力形式に変換
$output_data | ConvetTo-Csv | Out-String
# 書き込みファイルの文字コード
$enc_to = [System.Text.Encoding]::GetEncoding("UTF-8")
# 出力時のファイル名を設定
$output_file_name = "file_" + $val + ".csv"
# ファイル書き込みオブジェクトであるStreamWriterを生成
$writer = New-Object System.IO.StreamWriter("c:\temp\test_output.txt", $false, $enc_to, 20480000)
# ""を除外してファイルに書き込み
$writer.Write($output_data.Replace('"',''))
# StreamWriterオブジェクトを閉じる
$writer.Close()
$writer = $null
}
# StreamReaderオブジェクトを閉じる
$reader.Close()
$reader = $null
1.ファイル読み込み
# ファイル読み込み時の文字コードを設定
$read_encoder = [System.Text.Encoding]::GetEncoding("UTF-8")
# ファイル読み込みオブジェクトであるStreamReaderを生成
$reader = New-Object System.IO.StreamReader("file.csv", $enc_from, $false, 20480000)
# 行数カウンターを定義
$row = 0
$table = New-Object System.Data.DataTable
# 最後の1行が読み込まれるまで処理を繰り返す
while(!($reader.EndOfStream)) {
# ファイルを1行ずつ読み込み、","で分離する
$data = ($reader.ReadLine()).Split(",")
# 行カウンターを加算する
$row += 1
switch( $row ) {
# 行カウンターが1の場合 → ヘッダー
1 {
switch( $data ) {
default {
# DataTableのカラムを追加する
$table.Columns.Add($_)
}
}
}
# 行カウンターが1以外の場合 → ボディ
default {
# DataTableのレコードを追加する
$table.Rows.Add($data)
}
}
}
# StreamReaderオブジェクトを閉じる
$reader.Close()
$reader = $null
.ReadLine() メソッドでファイルから1行ずつデータを読み込む。
読み込んだデータを.Split() メソッドで配列形式に変換する。
DataTableは最初にカラムを定義したのちに、レコードを追加していくので、 $row が1のときはヘッダーとして、それ以外の場合はボディとしてデータを追加する。
参考
2.ユニークな分割対象値
# ユニークな配列を格納するハッシュセットを生成する
$unique_vals = [System.Collections.Generic.HashSet[string]]::new()
# ハッシュセットに対象カラムを追加する
$unique_vals.UnionWith($table."Col1")
3.対象データのフィルタリング
foreach($val in $unique_vals) {
# 抽出条件を定義
$filter = "Col1 = $($val)"
# データを抽出
$output_data = $table.Select($filter)
# 抽出データをファイル出力形式に変換
$output_data | ConvetTo-Csv | Out-String
}
DataTable の.Select() を使うことでデータをフィルタリングする。
フィルタリング方法として、DataView クラスの.RowFilter プロパティを利用する方法もある。
$table = New-Object System.Data.DataTable
# DataViewオブジェクトはDataTableオブジェクトを引数に受け、DataViewオブジェクトを返す
$view = New-Object System.Data.DataView($table)
# RowFilterプロパティでデータを絞り込む
$view.RowFilter = "Col1 = $($val)"
こちらも試したものの、DataTable 単体の時より30秒程度遅い結果となった。
DataView は対象データがソートされている状態ではDataTable よりも早くフィルタリングできるが、そうでない場合はデータ数が多くなると若干時間がかかる結果となった。
参考
DataView.RowFilter Vs DataTable.Select() vs DataTable.Rows.Find()
4.ファイル書き込み
foreach($val in $unique_vals) {
# 書き込みファイルの文字コード
$enc_to = [System.Text.Encoding]::GetEncoding("UTF-8")
# 出力時のファイル名を設定
$output_file_name = "file_" + $val + ".csv"
# ファイル書き込みオブジェクトであるStreamWriterを生成
$writer = New-Object System.IO.StreamWriter("c:\temp\test_output.txt", $false, $enc_to, 20480000)
# ""を除外してファイルに書き込み
$writer.Write($output_data.Replace('"',''))
$writer.Close()
$writer = $null
}
5.実行結果
40個のファイルを処理するのにかかった時間は5分41秒だった。
StreamReader で読み込んだ文字列データを、DataTable オブジェクトに行列を追加していくことで、テーブルデータとして扱うことができる。
.ReadLine() で1行ずつ読み込むことで、メモリ消費を抑えつつ高速に行追加できるため、読み込み速度が大幅に向上したことが今回の結果につながったといえる。
フィルタリングについても、もともとがテーブルデータであるため、Where-Objectなどでメモリにすべて取り込まなくてもよい点が更なる高速化につながったのだと考えられる。
まとめ
PowerShell で大規模 CSV(10万行以上)を扱う際には、読み込み方法によって速度が大きく変わることがわかった。
-
Import-Csvは扱いやすいが遅い -
Get-Content + ConvertFrom-Csvはやや高速 -
.NET StreamReaderは圧倒的に高速(今回最速) - PowerShell 7 は PowerShell 5 より高速