Kotlin DataFrameのExcel取込のパフォーマンス比較
はじめに
6月からログラスでエンジニアをしている遠藤です!
最近話題になっているJetBrains社純正ライブラリのKotlin DataFrameをご存知でしょうか。
Pythonのpandasにインスパイアされているだけあって、機能がとても充実していて二次元配列を扱う際のつらみを解消してくれる使い勝手の良いデータ操作ライブラリです。
今回は他のライブラリ(Apache POI
, FastExcel
)とのExcelファイル読み込みの性能比較を行なうことで、ログラスで扱っているような大容量のファイルでの使用に耐えうるのかを検証していきます。
関連記事:エクセル読み込みをPOIからFastExcelに置き換えてパフォーマンスを改善する
3行まとめ
- パフォーマンス面では
FastExcel
だけでなくApache POI
よりも遅い - 他のライブラリと比べて非常に扱いやすくシンプルにコードを書くことができる
- 数万行程度までのデータを対象とするのであれば十分選択肢に入る
パフォーマンス測定結果
JSONでの出力
Kotlin DataFrame | Apache POI | FastExcel | |
---|---|---|---|
メモリ使用量 | 16.2GB | 12.57GB | 4.65GB |
CPU時間 | 12,516ms | 10,309ms | 2,250ms |
Data Classへのパース
Kotlin DataFrame | Apache POI | FastExcel | |
---|---|---|---|
メモリ使用量 | 14.82GB | 12.51GB | 4.31GB |
CPU時間 | 13,277ms | 10,537ms | 2.598ms |
Kotlin DataFrameとは?
- 2023年1月にリリースされたJetBrains社純正のデータ操作ライブラリ
- 機能が充実していて使い勝手が良い
- セルの値をKotlinの型へ自動変換
- 行・列・セルへのアクセスがトップレベルから可能
- 操作系コマンドが充実
- CSV, JSON, Excelなど、さまざまなソースからデータを読み込むことができる
- 内部的には
Apache POI
を使用している
Excelを取り込む
Excelを読み込むには以下の依存関係の追加が必要です。(DataFrame Help Read | Dataframe)
implementation("org.jetbrains.kotlinx:dataframe:0.13.1")
implementation("org.jetbrains.kotlinx:dataframe-excel:0.13.1")
Kotlin DataFrame
はApache POI
やFastExcel
に比べて非常にシンプルなコードでセルの値を取得・出力することができます。
fun parse(inputStream: InputStream) {
val df = DataFrame.readExcel(inputStream)
println(df)
}
数字や文字列のカラムは自動でKotlinのDouble
やString
へキャストされ、また日付のカラムは自動でkotlinx.datetime.LocalDateTime
へキャストされます。
セルのエラー(#DIV/0!
など)はByte
型(0x07
)として返されるので検知は容易ですが、文字列として出力したい場合は注意が必要です。
またドキュメントに記載のあるExcel取込時に一意に型を指定するオプションですが、バージョン0.13.1時点ではまだリリースされておらず、次回以降のリリースに含まれる機能のようです。
参考:Apache POIやFastExcelのコード
Apache POI
fun parse(inputStream: InputStream) {
// 読み込むファイルのサイズが大きい場合は最大値を増やさないとエラーになるため
IOUtils.setByteArrayMaxOverride(200000000)
val workbook = XSSFWorkbook(inputStream)
val sheet = workbook.getSheetAt(0)
val iterator = sheet.rowIterator()
val data = iterator.asSequence().map {
listOf(
cellToString(it.getCell(0)),
cellToString(it.getCell(1)),
cellToString(it.getCell(2)),
cellToString(it.getCell(3)),
cellToString(it.getCell(4)),
cellToString(it.getCell(5)),
cellToString(it.getCell(6)),
cellToString(it.getCell(7)),
)
}.toList()
println("PoiParser")
println(JsonConverter().objectToJsonString(data))
}
private fun cellToString(cell: org.apache.poi.ss.usermodel.Cell?): String {
if (cell == null) return ""
return when (cell.cellType) {
CellType.BLANK -> ""
CellType.BOOLEAN -> cell.booleanCellValue.toString()
CellType.STRING -> cell.stringCellValue
CellType.NUMERIC -> { numericToString(cell) }
CellType.FORMULA -> {
when (cell.cachedFormulaResultType) {
CellType.BOOLEAN -> cell.booleanCellValue.toString()
CellType.STRING -> cell.stringCellValue
CellType.NUMERIC -> { numericToString(cell) }
CellType.ERROR -> cell.errorCellValue.toString()
else -> throw IllegalStateException()
}
}
CellType.ERROR -> cell.errorCellValue.toString()
else -> throw IllegalStateException()
}
}
private fun numericToString(cell: org.apache.poi.ss.usermodel.Cell): String {
return if (DateUtil.isCellDateFormatted(cell)) cell.dateCellValue.toString() else cell.numericCellValue.toString()
}
FastExcel
fun parse(inputStream: InputStream) {
val workbook = ReadableWorkbook(inputStream, ReadingOptions(true, false))
val sheet = workbook.firstSheet
val stream = sheet.openStream()
val data = stream.map {
listOf(
cellToString(it.getCell(0)),
cellToString(it.getCell(1)),
cellToString(it.getCell(2)),
cellToString(it.getCell(3)),
cellToString(it.getCell(4)),
cellToString(it.getCell(5)),
cellToString(it.getCell(6)),
cellToString(it.getCell(7)),
)
}.toList()
println("FastExcelParser")
println(JsonConverter().objectToJsonString(data))
}
private fun cellToString(cell: Cell?): String {
if (cell == null) return ""
if (cell.text.isNullOrBlank()) return ""
return if (isDateCell(cell)) cell.asDate().format(DateTimeFormatter.ofPattern("yyyy/M/d")) else cell.text
}
private fun isDateCell(cell: Cell): Boolean {
val dataFormatId = cell.dataFormatId ?: return false
// 14..22は日付形式のID
// 参考: https://www.iso.org/standard/71691.html
if (dataFormatId in 14..22) {
return true
}
val dataFormatString = cell.dataFormatString ?: return false
// POIさんの関数を使わせて頂いております
return DateUtil.isADateFormat(dataFormatId, dataFormatString)
}
DataFrameの出力
DataFrame型は直接println
で出力でき、以下のように出力されます。
DataFrame
にはtoJson()
メソッドも用意されており、そちらを用いた場合は以下のようなJSONが出力されます。
[
{"単純な数値":1.0,"文字列":"あいうえお","日付":"2024-07-21T00:00","関数(結果あり)":1.0,"数値を文字列に":456.0,"関数の結果が空文字":"","関数エラー":7,"NA値":42},
{"単純な数値":2.0,"文字列":"あいうえお","日付":"2024-07-21T00:00","関数(結果あり)":2.0,"数値を文字列に":456.0,"関数の結果が空文字":"","関数エラー":7,"NA値":42},
{"単純な数値":3.0,"文字列":"あいうえお","日付":"2024-07-21T00:00","関数(結果あり)":4.0,"数値を文字列に":456.0,"関数の結果が空文字":"","関数エラー":7,"NA値":42},
{"単純な数値":4.0,"文字列":"あいうえお","日付":"2024-07-21T00:00","関数(結果あり)":8.0,"数値を文字列に":456.0,"関数の結果が空文字":"","関数エラー":7,"NA値":42},
{"単純な数値":5.0,"文字列":"あいうえお","日付":"2024-07-21T00:00","関数(結果あり)":16.0,"数値を文字列に":456.0,"関数の結果が空文字":"","関数エラー":7,"NA値":42}
]
パフォーマンス測定
上記のコードを用いて実際にライブラリ毎のパフォーマンスを測定してみます。
今回は IntelliJ のプロファイラーを使って確認しました。
使用するExcelは、50万行x8列=19.7MBのサイズで作成しています。
Jsonでの出力
Kotlin DataFrame
の結果を見てみましょう。
※読み込むファイルのサイズが大きい場合は最大値を増やさないとエラーになるため、IOUtilsで上限値を上げています。
パフォーマンス測定結果
メモリ使用量:16.2GB
CPU時間:12,516ms
DataFrameインスタンスを作成する箇所でメモリもCPU時間も多く使用しており、Apache POI
よりもさらに時間がかかっています。
実はKotlin DataFrame
の内部ではApache POI
のWorkbookFactory
が使用されており、それをDataFrame
に詰め直しているため性能が悪くなってしまっています。
POI Streaming
のような機能があればと思いましたが現状は存在しないようでした。
Apache POI(メモリ使用量:12.57GB, CPU時間:10,309ms)
メモリ使用量:12.57GB
CPU時間:10,309ms
FastExcel(メモリ使用量:4.65GB, CPU時間:2,250ms)
メモリ使用量:4.65GB
CPU時間:2,250ms
Data Classへのパース
Kotlin DataFrame
にはデータをData Classへ容易に変換することができるconvertToメソッドが存在します。
そこで今度はData Classへの詰め替え速度も含めて計測してみます。
Kotlin DataFrame
以下のように@DataSchema
と@ColumnName
を用いることでヘッダーから自動的にカラムを選択して読み込むことが可能です
@DataSchema
data class ImportedData(
@ColumnName("単純な数値")
val simpleNum: Int,
@ColumnName("文字列")
val string: String,
@ColumnName("日付")
val date: String,
@ColumnName("関数(結果あり)")
val formulaResult: Int,
@ColumnName("数値を文字列に")
val numToString: String,
@ColumnName("関数の結果が空文字")
val resultEmpty: String,
@ColumnName("関数エラー")
val formulaError: String,
@ColumnName("NA値")
val naNum: String,
)
class DataFrameParser {
fun parseForClass(inputStream: InputStream) {
IOUtils.setByteArrayMaxOverride(200000000)
val df = DataFrame.readExcel(inputStream).convertTo<ImportedData> {
convert<Int>()
convert<String>()
parser { it.format("YYYY/M/d") }
convert<Int>()
convert<String>()
convert<String>()
convert<String>().with { it.toString() }
}
println("DataFrameParser")
println(df.toList())
}
}
パフォーマンス測定結果
メモリ使用量:14.82GB
CPU時間:13,277ms
結果を見て分かるようにただ詰め替えているだけでDataFrame生成処理時に差し込むような仕組みではないため、期待したようなパフォーマンスの向上はありませんでした。
Apache POI(メモリ使用量:12.51GB, CPU時間:10,537ms)
メモリ使用量:12.51GB
CPU時間:10,537ms
FastExcel:(メモリ使用量:4.31GB, CPU時間:2.598ms)
メモリ使用量:4.31GB
CPU時間:2.598ms
まとめ
パフォーマンス測定結果
JSONでの出力
Kotlin DataFrame | Apache POI | FastExcel | |
---|---|---|---|
メモリ使用量 | 16.2GB | 12.57GB | 4.65GB |
CPU時間 | 12,516ms | 10,309ms | 2,250ms |
Data Classへのパース
Kotlin DataFrame | Apache POI | FastExcel | |
---|---|---|---|
メモリ使用量 | 14.82GB | 12.51GB | 4.31GB |
CPU時間 | 13,277ms | 10,537ms | 2.598ms |
総評
パフォーマンス面ではFastExcel
だけでなくApache POI
にも後塵を拝する結果となりました。
とはいえ他のライブラリと比べて非常に扱いやすくシンプルにコードを書くことができるので、数万行程度までのデータを対象とするのであれば十分選択肢に入るのではないかと思います。
最後に
今回はJetBrains社純正ライブラリであるKotlin DataFrameのパフォーマンスについてまとめました。
Kotlin DataFrame
はまだリリースされてから一年半程度しか経っていないのもあり、今後POI Steaming
などの導入により性能面が改善されることも十分期待できます。
簡単なコードを書いただけでしたがそれでも開発者体験がとても良かったので、今後もアップデートをしっかりとチェックしていきたいです。
Discussion