HSP3でのテキストファイル読み込み

 HSP3を使ってテキストファイルを読み込む際は、どんな命令を使っていますか? HSP3にはメモリノートパッドという機能があり、テキストファイルはこの機能(note系命令)を使うと手軽で便利です。 簡単手軽に任意の指定行を読み込むことができます。また、任意の行の追加や削除も簡単なのでとても便利な機能です。

 しかしそんな便利な機能ですが、読み込みが遅いと感じたことはないでしょうか。 ちょっとだけ検証してみました。

検証用データを作成

 検証用に大きなテキストファイルが必要なので準備します。 テキストエディタで作ってもよいのですが、ここを読んだ人もテストをしやすくするためにHSP3で作ってみます。


;
;	大きなテキストデータを作成するツール
;

; データの数、行数
num = 100000

; 出力ファイル名
nameFile = "テストデータ_" + num + "行.txt"

; データの作成と保存
mes "データを作成しています。"
sdim data, num * 10
repeat num
	data += "12345678\n"
loop
mes "データが作成できました。"
mes "行数 :" + num

; ファイル保存
bsave nameFile, data, strlen(data)
mes "サイズ:" + strf( "%5.2f", double( strlen(data) ) / 1024 ) + " KB"
mes "出力完了"
    

 実行すると 100000 行のテキストデータが作成されます。 メモリノートパッドで扱いやすいように、データの区切りは「改行」です。 num がデータの個数なので、お好みで変更してください。

メモリノートパッドによる読み込みテスト

 作成した検証データをメモリノートパッドで読み込んでみます。 読み込んだ文字列は、HSP3内で数値として使用したいので、数値に変換して配列変数に格納します。 進捗状況も知りたいので line 命令で簡単に進捗状況を表示しています。

 時間計測は d3timer 命令で行います。 最後に vsave で保存してますが、ちゃんと読み込めたかどうかの確認に使用してください。


;
;	メモリノートパッド 読み込みテスト
;
; テキストファイルに保存した数値を読み込んで、変数に格納するまでの時間を計測します。
; note 系命令を使って読み込みます。
; 
#include "d3m.hsp"
#include "hspda.as"

timeStart = d3timer()

; 読み込むファイルのファイル名
;fileName = "テストデータ_200000行.txt"
fileName = "テストデータ_100000行.txt"

; 読み込んだデータを格納する変数
;dim data, 200000
dim data, 100000

; 進捗グラフの長さ
lengthBar = ginfo_winx

; --------------------
; ファイル読み込み
; --------------------
mes "ファイル:" + fileName
exist fileName
z = strsize
if z < 0 : mes "ファイルが見つかりません。" : end
sdim txtData, z

notesel  txtData		; 対象バッファ指定
noteload fileName		; 対象バッファ読み込み
numLineMax = notemax		; メモリノートパッドの行数
mes "データ数:" + numLineMax

; 1行ずつ読み込みながら変換
idx = 0
pos 0, 0
repeat numLineMax
	noteget b, idx
	data(cnt) = int( b )
	idx++

	; 進捗状況を表示
	line int( double(cnt) / numLineMax * lengthBar), 100
	await 0
loop

; --------------------
;	経過時間
; --------------------
pos 0, 100
timeEnd = d3timer()
t = double( timeEnd - timeStart ) / 1000
mes "経過時間:" + strf("%7.3f",t) + " sec"

vsave_start
vsave_put data
vsave_end "note.dat"
    

実行して時間を計測してみます。

 読み込んだデータ:977KB / 100000 行 / 100000 データ
 かかった時間  :89.934 秒

かかった時間も気になりますが、読み込みの速度変化が気になったと思います。 序盤は早いのですが、処理が進むほど速度がどんどん遅くなっていきました。

line 命令の行をコメントにすると少し早くなります。

 進捗表示無しでかかった時間  : 83.311秒

描画処理で遅くなっているはずですが、あまり変わりませんでした。

bload命令による読み込みテスト

 比較のため、同じデータを bload 命令で読み込んでみます。 メモリノートパッドとの比較なので、先程同様に数値に変換して配列変数に格納します。 進捗状況の表示や時間計測なども、基本的に同じです。

 方針としては簡単で、1行ずつ文字列として読み込んでから数値変換しています。 1文字ずつ取り出して前の文字に連結。改行コードを見つけたら文字列から数値に変換。次の文字へという繰り返しです。


;
;	bload 読み込みテスト
;
; テキストファイルに保存した数値を読み込んで、変数に格納するまでの時間を計測します。
; bload 命令を使って読み込みます。
; 
#include "d3m.hsp"
#include "hspda.as"

timeStart = d3timer()

; 読み込むファイルのファイル名
;fileName = "テストデータ_200000行.txt"
fileName = "テストデータ_100000行.txt"

; 読み込んだデータを格納する変数
;dim data, 200000
dim data, 100000

; 進捗グラフの長さ
lengthBar = ginfo_winx

; --------------------
; ファイル読み込み
; --------------------
mes "ファイル:" + fileName
exist fileName
z = strsize
if z < 0 : mes "ファイルが見つかりません。" : end
sdim txtData, z

bload fileName, txtData, -1
sizeFile = strsize
mes "ファイルサイズ:" + sizeFile + " byte"

; 読み込んだデータを変換
countData = 0
s = ""
pos 0, 0
repeat sizeFile
	b = peek(txtData, cnt)
	if (b = 13) or (b = 10) {
		; 改行(区切り文字)
		if idx ! 0 {
			idx = 0
			data( countData ) = int( s )
			countData++
		}
	} else {
		poke s, idx, b
		idx++
	}
	; 進捗状況を表示
	line int( double(cnt) / sizeFile * lengthBar), 100
	await 0
loop
pos 0, 60
mes "データ数:" + countData

; --------------------
;	経過時間
; --------------------
pos 0, 100
timeEnd = d3timer()
t = double( timeEnd - timeStart ) / 1000
mes "経過時間:" + strf("%7.3f",t) + " sec"

vsave_start
vsave_put data
vsave_end "bload.dat"
    

実行して時間を計測してみます。

 読み込んだデータ:977KB / 100000 行 / 100000 データ
 かかった時間  :35.067 秒

実行直後はメモリノートパッドの方が早い気がしますが、こちらは速度が一定です。 なお、line 命令の行をコメントにすると少し早くなります。

 進捗表示無しでかかった時間  :2.784 秒

圧倒的にline命令が足を引っ張っていますね。

メモリノートパッド vs bload

 結果発表。line命令を挟むと不必要に遅くなるので、進捗表示無しでかかった時間で比較します。 結果は以下の通り。200000行のデータも読み込んでみたので、そちらの結果も記載しました。

読み込んだデータ977KB / 100000 行 / 100000 データ
メモリノートパッド83.311秒
bload 2.784 秒

データ数を倍にしました。

読み込んだデータ1.90MB / 200000 行 / 200000 データ
メモリノートパッド336.998 秒
bload 5.419 秒

 メモリノートパッドの方が圧倒的に遅く、bload よりも 100000 行では 30 倍、200000 行では 62 倍遅い結果となりました。 行数が多いほど遅くなっている事がわかります。 また、メモリノートパッドによる読み込み時間が、データ量2倍に対して4倍の時間がかかっている点が気になりますね。 一方で、bloadはデータ量2倍に対して2倍の時間がかかっています。

メモリノートパッドが遅い原因

 サンプルは省略しますが、同じファイルサイズで行数を減らせばメモリノートパッドでの読み込み速度はbload並みに早くなります。 これらの事実を踏まえて原因を考えてみます。

 メモリノートパッドが遅い原因の心当たりといえば、序盤の読み込み速度と後半の読み込み速度に差があった点です。 noteget命令で行を読み込む際に、上の方の行と下の方の行で速度が違う気がします。 どれだけの違いが出るのか検証用にプログラムを書いてみました。


;
;	note 系命令が遅いポイントの調査
;
; 
#include "d3m.hsp"


; 読み込むファイルのファイル名
;fileName = "テストデータ_200000行.txt"
fileName = "テストデータ_100000行.txt"

; 試行回数
numTest = 1000

; --------------------
; テキスト読み込み
; --------------------
mes "ファイル:" + fileName
exist fileName
z = strsize
if z < 0 : mes "ファイルが見つかりません。" : end
sdim txtData, z

notesel  txtData		; 対象バッファ指定
noteload fileName		; 対象バッファ読み込み
numLineMax = notemax		; メモリノートパッドの行数
mes "データ数:" + numLineMax

; --------------------
;	最初の行を読み込み
; --------------------
timeStart = d3timer()
repeat numTest
	noteget b, 0
loop
gosub *l_time

; --------------------
;	最後の行を読み込み
; --------------------
timeStart = d3timer()
z = numLineMax - 1
repeat numTest
	noteget b, z
loop
gosub *l_time

stop

; --------------------
;	経過時間
; --------------------
*l_time
	timeEnd = d3timer()
	t = double( timeEnd - timeStart ) / 1000
	mes "経過時間:" + strf("%7.3f",t) + " sec"
	return
    

 最初の行と最後の行をnotegetで繰り返し読み込んで時間を計測しました。 試行回数 1000 回の結果は次のとおりです。

データ行 先頭行 [秒] 最終行 [秒]
100000行0.0001.684
200000行0.0003.343

 最終行の読み込みにかかる時間が、データ数2倍になった場合、かかる時間も2倍になっています。 もう一つぐらいはデータを確認したほうがいいのですがそろそろ飽きてきました。おそらく読み込み時間は行数に比例していそうです。 全行読み込みの場合に、データ数2倍の際に読み込み時間が4(=2の2乗)倍になるというのとも辻褄が合います。 この様子だと確認はしていませんが、データ量が3倍なら時間は9(=3^2)倍、4倍なら時間は16(=4^2)倍かかることになると思います。(間違っていたらご指摘ください。)

 おそらくですが、noteget命令は指定行を見つけるために、実行されるたびに先頭行から「改行」をカウントしているのではないでしょうか。 これなら行数が多いデータほど読み込みに時間がかかる理由に納得ができます。

 データ行数が多いテキストファイルを読み込む場合は、メモリノートパッド命令を避けてbloadでの読み込みを検討すべきのようです。 どうしても難しいようなら、テキストファイルを複数のファイルに分割しておくのもいい対策だと思います。

vload命令による読み込みテスト

せっかくなので、vload命令でも読み込んでみます。


;
;	vload 読み込みテスト
;
; データファイルを読み込んで、変数に格納するまでの時間を計測します。
; vload 命令を使って読み込みます。
; 
#include "d3m.hsp"
#include "hspda.as"

timeStart = d3timer()

; 読み込むファイルのファイル名
fileName = "note.dat"
;fileName = "bload.dat"

; --------------------
; ファイル読み込み
; --------------------
vload fileName
vload_get data
vload_end

; --------------------
;	経過時間
; --------------------
pos 0, 100
timeEnd = d3timer()
t = double( timeEnd - timeStart ) / 1000
mes "経過時間:" + strf("%7.3f",t) + " sec"
    

 読み込んだデータ:977KB / 100000 行 / 100000 データ
 かかった時間  :0.000 秒

 bloadの場合、読み込んだあとに変換処理をするので遅くなってしまいます。 一方vload命令はその必要がないので大変高速です。

まとめ

 HSP3で大きなデータをファイルから読み込みたい場合は、vloadが最速で実装も簡単です。ただし、データの準備のためにツールを自作する必要があります。

 メモリノートパッド命令を使用する場合は、ファイル1つあたりの行数が多くなりすぎないように気を遣えば、速度は問題にならないし、なによりデータ準備の手軽さがあります。

 行数が多いテキストファイルを読み込まなければいけない状況ではbload命令必須です。 バイナリデータなのでテキストデータへの変換作業が必要なので多少手間がかかります。

 行数が多いテキストデータは、複数ファイルに分けて準備しておきメモリノートパッド命令で読み込む。 読み込んだデータはvsave命令でファイルに保存。使うときはvloadで読み込む。という流れが一番いいような気がします。 テキストファイルをbload命令で読み込むのは、インターネット上で配布されているデータを使うときぐらいでしょうか。素数とか。

追記:2021/05/24

この記事を上げたのが2021年05月16日、それからしばらくしてこんなツイートが。

そ・れ・だ・ッ!!

ということで、getstrの存在をすっかり忘れていたので再検証です。

getstr命令による読み込みテスト

 ほとんどメモリノートパッドと同じスクリプトです。書き換えるのは1箇所だけ。


noteget b, idx
idx++
    

この部分を、


getstr b, txtData, idx
idx += strsize
    

 このように書き換えます。getstrは指定した読み込み開始位置から、改行が見つかるまで読み込んでくれます。 しかも読み込んだバイト数もstrsizeに返すので、次の読み込み位置もわかります。これは便利。


;
;	getstr 読み込みテスト
;
; テキストファイルに保存した数値を読み込んで、変数に格納するまでの時間を計測します。
; note 系命令を使って読み込みます。
; 
#include "d3m.hsp"
#include "hspda.as"

timeStart = d3timer()

; 読み込むファイルのファイル名
;fileName = "テストデータ_200000行.txt"
fileName = "テストデータ_100000行.txt"

; 読み込んだデータを格納する変数
;dim data, 200000
dim data, 100000

; 進捗グラフの長さ
lengthBar = ginfo_winx

; --------------------
; ファイル読み込み
; --------------------
mes "ファイル:" + fileName
exist fileName
z = strsize
if z < 0 : mes "ファイルが見つかりません。" : end
sdim txtData, z

notesel  txtData		; 対象バッファ指定
noteload fileName		; 対象バッファ読み込み
numLineMax = notemax	; メモリノートパッドの行数
mes "データ数:" + numLineMax

; 1行ずつ読み込みながら変換
b = ""
idx = 0
pos 0, 0
repeat numLineMax
	getstr b, txtData, idx
	idx += strsize
	data(cnt) = int( b )

	; 進捗状況を表示
	;line int( double(cnt) / numLineMax * lengthBar), 100
	await 0
loop

; --------------------
;	経過時間
; --------------------
pos 0, 100
timeEnd = d3timer()
t = double( timeEnd - timeStart ) / 1000
mes "経過時間:" + strf("%7.3f",t) + " sec"

vsave_start
vsave_put data
vsave_end "getstr.dat"
    

実行して時間を計測してみます。

 読み込んだデータ:977KB / 100000 行 / 100000 データ  かかった時間  :3.592 秒

読み込み速度は終始一定のようです。

 進捗表示無しでかかった時間  :0.297 秒

 速い。notegetと置き換えるだけと手軽。読み込み開始位置を配列変数に記録しておけば、後から任意の行にランダムアクセスする際も高速。最高ですねこれ。 実は、昨日までbload使って高速読み込みできるモジュール作ってたんですが、いらなくなりましたね。良かった。

再度結果比較

 再度結果発表。進捗表示無しでかかった時間で比較します。

読み込んだデータ977KB
100000 行
100000 データ
1.90MB
200000 行
200000 データ
メモリノートパッド83.311秒336.998 秒
bload 2.784 秒5.419 秒
getstr 0.297 秒0.578 秒

 メモリノートパッド…というよりnotegetの遅さが際立っていますね。 5分以上かかる作業が0.5秒まで短縮ってすごすぎです。

 getstr命令もbloadで書いたとき同様に、読み込むデータ量に比例しているようです。 データ数が増えても安心して使えますね。

まとめ その2

 相変わらずvload命令による読み込みテストの結果を見て分かる通り、圧倒的にvload命令が最速です。 可能な限り、vloadによる実装をおすすめします。

 一方、メモリノートパッドは手軽ですが、noteget命令で全行読み込もうとすると行数が多いほど指数関数的に読み込み時間が増加します。 しかし、単純に頭から全行読み込みたいだけならgetstrを使うことでとても高速に読み込むことができるようになります。 この場合のnotegetからgetstrへの置き換えはとても簡単なので、使用をためらう理由はないでしょう。

 bload命令での実装は…よほど特殊な目的がない限り選択する必要はありません。(なんだかぜんぜん違う結論に到達しているな…´・ω・`)

 正直なところ「メモリノートパッド命令で読み込んだデータはメモリノートパッド系の命令でしか処理できない」と思い込んでいた部分もありました。 初心者の頃にそう思い込んで理解してしまい、メモリノートパッド使いにくいなーとずっと思っていました。 マニュアル見返すとそんなことはなく、他の文字列操作命令と合わせる事もできるのでとても便利な命令です。 初心に帰るって大事ですね。

 最後にgetstrが速いと教えてくれた明(@kmzwakr)さんに感謝。

追記:2021/05/29

再びこんな情報が。

たしかにこれもありましたね。

split 命令による読み込みテスト

 split命令を使用しての全行読み込みは簡単で、これまでのようにループは必要ありません。


sdim b, 64, numLineMax
split txtData, "\n", b
    

 これだけで全行を読み込んで、配列変数に格納完了です。 少ない行ならsdimで確保しなくても自動拡張で対応できますが、今回は多いので事前に確保しておきます。1行あたりの文字数は、自動拡張されるので気にしなくていいです。 また今回の検証では、読み込んだ後にint型整数値に変換する後処理を必ず加えることにしているのでこの様になりました。


;
;	split 読み込みテスト
;
; テキストファイルに保存した数値を読み込んで、変数に格納するまでの時間を計測します。
; note 系命令を使って読み込みます。
; 
#include "d3m.hsp"
#include "hspda.as"

timeStart = d3timer()

; 読み込むファイルのファイル名
;fileName = "テストデータ_200000行.txt"
fileName = "テストデータ_100000行.txt"

; 読み込んだデータを格納する変数
;dim data, 200000
dim data, 100000

; 進捗グラフの長さ
lengthBar = ginfo_winx

; --------------------
; ファイル読み込み
; --------------------
mes "ファイル:" + fileName
exist fileName
z = strsize
if z < 0 : mes "ファイルが見つかりません。" : end
sdim txtData, z

notesel  txtData		; 対象バッファ指定
noteload fileName		; 対象バッファ読み込み
numLineMax = notemax	; メモリノートパッドの行数
mes "データ数:" + numLineMax

;	改行区切りで読み込み
sdim b, 64, numLineMax
split txtData, "\n", b
; 1行ずつ変換
idx = 0
pos 0, 0
repeat numLineMax
	data(cnt) = int( b(cnt) )
	idx++
	; 進捗状況を表示
	;line int( double(cnt) / numLineMax * lengthBar), 100
	await 0
loop

; --------------------
;	経過時間
; --------------------
pos 0, 100
timeEnd = d3timer()
t = double( timeEnd - timeStart ) / 1000
mes "経過時間:" + strf("%7.3f",t) + " sec"

vsave_start
vsave_put data
vsave_end "note.dat"
    

 読み込んだデータ:977KB / 100000 行 / 100000 データ  かかった時間  :3.441 秒
 進捗表示無しでかかった時間  :0.294 秒
読み込み速度は終始一定。

速度も高速ですが、ループの記述がいらない上に読み込み後のランダムアクセスが手軽なのも魅力的ですね。

再再度結果比較

 再再度結果発表。データを全行読み込むためにかかった時間を進捗表示無しでかかった時間として比較。

読み込んだデータ977KB
100000 行
100000 データ
1.90MB
200000 行
200000 データ
noteget83.311秒336.998 秒
bload 2.784 秒5.419 秒
getstr 0.297 秒0.578 秒
split 0.294 秒0.583 秒

 なお、notegetgetstrsplitはメモリノートパッドのnoteloadを使って読み込み。 bloadbloadによる読み込み。

 結果を見てみると、notegetの遅さが目立ちます。 notegetはデータ数2倍に対して、4(=2^2)倍の時間がかかっています。 一方その他の方法では、時間は2倍になっています。

 getstrsplitは、計測誤差があるので同じ成績と見ていいと思います。 今回のテスト条件下では、getstrsplitの2択ですね。

getstr VS split

getstrsplitはどう使い分ければいいのでしょうか。

速度
どちらも同じぐらい速い。

書きやすさ
getstr : repeatによる全行ループが必要。
split  : ループはいらない。格納用に配列変数を事前にsdimでテキストの行数分だけ確保しておくことを推奨。

任意の行への再アクセス
getstr : 読み込み時に各行の先頭アドレスを配列変数に格納しておく手間をかければ後は簡単。
split  : 配列の要素番号=行番号-1 なので簡単。

メモリ消費
どちらもnoteloadでファイルまるごと読み込むので、読み込むテキストファイルサイズ分は必ず消費する。
getstr : さらに1行分だけ消費。
split  : さらに全行分消費。

 split命令だと読み込みも、読み込んだあとの処理も簡単です。 しかし全行読み込んだ場合、読み込むテキストを二重に読み込むことになるのでメモリ消費は倍になります。 getstr命令はsplitと比較すると手間は増えますが、メモリを節約することができます。状況によって使い分けるのがいいですね。

 メモリ消費も速度もどちらも気になる場合は、splitで読み込んだデータをvsaveで保存しておき、配布時はvloadで読み込めるようにしておけば、メモリの問題も速度の問題も両方解決します。 またgetstrで調べた全行の先頭アドレスを格納した配列変数をvsaveで保存しておけば、次回起動からは全行getstrで調べ直さなくてもアドレスが分かっているのでgetstrを使ってすぐに任意の行にランダムアクセスできます。 ただし読み込み元のテキストに何も変更が加えられないとう条件付きです。あ、bloadを使えばもっとメモリ節約できますね。

まとめ その3

 今回は行数多めのテキストファイルを全行読み込むという条件だったのでnotegetは遅い! という結論になっていますが、noteloadでファイル読み込み直後になんの準備もなく任意の行にアクセスできる手軽さはとても重要で、速度を犠牲にする価値は十分あります。 行へにアクセスする回数や頻度が少ない場合は、シンプルに記述できるnotegetがベストな選択です。

 各行へのアクセス頻度が高い場合、アクセスに速度が求められる場合は、getstrsplitを使った方法がおすすめです。 notegetよりも記述が複雑になるので、間違えないように注意は必要になります。

 どの方法も一長一短あるので、状況に合わせた選択が必要ですね。

 今回は、令掛ベイン(@vain0x) さんに感謝でした。