何MBもあるような巨大なtxtファイルのn行目だけを読み込みたい瞬間ってありますよね。
普通にやるなら、String
として読み込んで、string.components(separatedBy:"\n")[index]
かstring.split(separator:"\n", omittingEmptySubsequences: false)[index]
みたいな感じでなりそうですが、これだとファイル全体を分割しているので効率が悪そうです。
そこで実験してなるべく速くn行目を取得する方法を探してみました。
実験
用いたファイルは青空文庫のリスト「公開中 作家別作品一覧拡充版:全て(CSV形式、zip圧縮)」で、ダウンロードした時点で17000行ちょい、サイズにして15MBほどあります。計測は
- ファイルの読み込み
- 分割
- 1000, 2000, 5000, 8000, 10000, 12000, 15000, 17000行目を同時に取得し、表示
という処理を行わせています。
1.components
func testPerformanceGetNthLineComponents() throws{
self.measure {
let string:String
do{
guard let path = Bundle(for: type(of: self)).path(forResource: "aozora", ofType: "csv") else {
print("ファイルが存在しません")
return
}
string = try String(contentsOfFile: path, encoding: String.Encoding.utf8)
} catch let error {
print("ファイルが存在しません: \(error)")
string = ""
}
let splited = string.components(separatedBy: "\n")
let strings = [1000, 2000, 5000, 8000, 10000, 12000, 15000, 17000].map{splited[$0]}
print(strings)
}
}
わかりやすくて良い方法で、平均0.192秒とそこそこのスピードです。
[Time, seconds]
average: 0.192,
relative standard deviation: 5.157%,
values: [0.221948, 0.188873, 0.188767, 0.188911, 0.189229, 0.188864, 0.188640, 0.188775, 0.188801, 0.189360]
2. enumerateLines
文字列を1行ずつ取得できるenumerateLines
を使えば、全体を分割せずとも必要な部分まで読んでstop
すれば良いことになります。
func testPerformanceGetNthLineEnumerateLines() throws{
self.measure {
let string:String
do{
guard let path = Bundle(for: type(of: self)).path(forResource: "aozora", ofType: "csv") else {
print("ファイルが存在しません")
return
}
string = try String(contentsOfFile: path, encoding: String.Encoding.utf8)
} catch let error {
print("ファイルが存在しません: \(error)")
string = ""
}
var indices = [1000, 2000, 5000, 8000, 10000, 12000, 15000, 17000].sorted().makeIterator()
guard var target = indices.next() else{
return
}
var count = 0
var result:[String] = []
string.enumerateLines(invoking: {line, stop in
if target == count{
result.append(line)
if let _target = indices.next(){
target = _target
}else{
stop = true
}
}
count += 1
})
print(result)
}
}
初回が異様に遅い(飛び値)ので平均ではcomponents
に負けていますが、初回を除いた平均は0.101001とcomponents
より高速です。
XCTestでPerformance測定をすると初回が異様に遅くなりがちなのはなぜなんでしょうか……。
[Time, seconds]
average: 0.362,
relative standard deviation: 216.306%,
values: [2.711354, 0.101003, 0.100871, 0.100783, 0.101008, 0.101088, 0.100787, 0.101444, 0.101147, 0.100880]
3.Data
として読み込んでwithUnsafeBytes
でsplit
する
String
にする前にsplit
してn行目を取ってしまう、というやり方です。
func testPerformanceGetNthLineDataAndWithUnsafeBytes() throws{
self.measure {
let data:Data
do{
guard let path = Bundle(for: type(of: self)).path(forResource: "aozora", ofType: "csv") else {
print("ファイルが存在しません")
return
}
let url = URL(fileURLWithPath: path)
data = try Data(contentsOf: url)
} catch let error {
print("ファイルが存在しません: \(error)")
data = Data()
}
let bytes = data.withUnsafeBytes {
$0.split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: false)
}
let result = [1000, 2000, 5000, 8000, 10000, 12000, 15000, 17000].compactMap{String(bytes: bytes[$0], encoding: .utf8)}
print(result)
}
}
何をやっているのかわかりづらくなった分、速度がグンと高速化します。平均0.039と、enumuratedLines
の2.6倍、components
の4.9倍高速です。
[Time, seconds]
average: 0.039,
relative standard deviation: 37.924%,
values: [0.084047, 0.037760, 0.034003, 0.033994, 0.034018, 0.033920, 0.034091, 0.033933, 0.034028, 0.033961]
4.Data
でwithUnsafeBytes
で1バイトずつ調べる
3の方法ではやはり全体をsplit
していますが、この方法なら必要な行まで読んだ時点でstop
できるのでその分高速化するはずです。
func testPerformanceGetNthLineDataAndWithUnsafeBytesAndScaning() throws{
self.measure {
let data:Data
do{
guard let path = Bundle(for: type(of: self)).path(forResource: "aozora", ofType: "csv") else {
print("ファイルが存在しません")
return
}
let url = URL(fileURLWithPath: path)
data = try Data(contentsOf: url)
} catch let error {
print("ファイルが存在しません: \(error)")
data = Data()
}
var indicesIterator = [1000, 2000, 5000, 8000, 10000, 12000, 15000, 17000].sorted().makeIterator()
guard var targetIndex = indicesIterator.next() else{
return
}
let bytes:[[UInt8]] = data.withUnsafeBytes {
var results:[[UInt8]] = []
var result:[UInt8] = []
var count = 0
for byte in $0{
let isNewLine = byte == UInt8(ascii: "\n")
if count == targetIndex && !isNewLine{
result.append(byte)
}
if count > targetIndex{
results.append(result)
result = []
if let _targetIndex = indicesIterator.next(){
targetIndex = _targetIndex
if count == targetIndex{
result.append(byte)
}
}else{
break
}
}
if isNewLine{
count += 1
}
}
if !result.isEmpty{
results.append(string)
}
return results
}
print(bytes.map{String(bytes: $0, encoding: .utf8)})
}
}
平均で0.026秒と、3
の方法よりも高速になりました。最初のcomponents
と比較すると7.4倍ほど高速化できたことになります。思いついた限りではこれが一番速い方法でした。
[Time, seconds]
average: 0.026,
relative standard deviation: 45.925%,
values: [0.061011, 0.029828, 0.023706, 0.020109, 0.020274, 0.025639, 0.020205, 0.020277, 0.020406, 0.020213]
10/2追記
if count == targetIndex && !isNewLine{
result.append(byte)
}
↓↓↓↓↓
if count == targetIndex && !isNewLine{
result.append(byte)
continue //count == targetIndexかつ!isNewLineなので、これ以降の行が呼ばれることはない。
}
とした方がパフォーマンスが上がるかな?と試したのですが、なんとcontinue
がない方が速いという不思議な結果になりました。continue
はなしでいきましょう。
[Time, seconds]
average: 0.043,
relative standard deviation: 31.214%,
values: [0.082764, 0.040316, 0.038075, 0.038079, 0.037942, 0.038081, 0.038038, 0.037882, 0.038093, 0.038405]
結論
速度が必要な場合は4番の方法でいきましょう。もっと速く取得できる方法をご存知の方がいたら是非教えてください🙏🙏🙏