TFileStreamでバッファを使い高速化する

 TFileStreamは主にファイル入出力で使うクラスで、TStream → THandleStream → TFileStream と継承してます。これらのクラスにはバッファが無く、小規模なRead・Writeではパフォーマンスが落ちてしまいます。んなわけで、私はいつも自前のバッファを通してますが、なんか毎回やるのも面倒なんで、クラスを作ってしまいました。まずはソース、その後に解説および考察が続きます。

ソース


unit BufStream;

interface

uses
  Classes, SysUtils;

type
  TBufFileStream = class(TFileStream)
  private
    FBuf: PChar;          //バッファ
    FBufSize: Longint;    //バッファサイズ
    FBufPos: Longint;     //バッファ内の位置
    FBufEnd: Longint;     //バッファの終わり
    FFilePos: Longint;    //ファイルの位置(Seekで必要)
  public
    constructor Create(const FileName: string; Mode: Word; BufSize: Byte = 16);
    destructor Destroy; override;
    function Read(var Buffer; Count: Longint): Longint; override;
    function Write(const Buffer; Count: Longint): Longint; override; //あるだけ
    function Seek(Offset: Longint; Origin: Word): Longint; override;
  end;

implementation

{ TBufFileStream }

constructor TBufFileStream.Create(const FileName: string; Mode: Word;
  BufSize: Byte = 16);
begin
  inherited Create(FileName, Mode);
  FBufSize := BufSize * 1024;
  GetMem(FBuf, FBufSize);
end;

destructor TBufFileStream.Destroy;
begin
  FreeMem(FBuf);
  inherited Destroy;
end;

function TBufFileStream.Read(var Buffer; Count: Integer): Longint;
var
  Rest, Len, ForwardSize: Longint;
begin
  if Count > FBufSize then
    raise Exception.Create('バッファの大きさを越えています。');

  ForwardSize := Count;
  if FBufEnd = 0 then //バッファが無い場合
  begin
    FBufEnd := inherited Read(FBuf[0], FBufSize);
    if FBufEnd = 0 then
    begin
      Result := 0;
      Exit;
    end;
    if FBufEnd < Count then ForwardSize := FBufEnd; //長さ調整
  end
  else
  begin
    Rest := FBufEnd - FBufPos; //バッファの残り
    if Count > Rest then      //バッファ内にない場合
    begin
      Move(FBuf[FBufSize - Rest], FBuf[0], Rest);          //残りを先頭にずらす
      Len := inherited Read(FBuf[Rest], FBufSize - Rest); //追加読み込み
      FBufEnd := Rest + Len;
      if Len = 0 then
      begin
        if FBufEnd = 0 then
        begin
          Result := 0;
          Exit;
        end;
        if FBufEnd < Count then ForwardSize := FBufEnd; //長さ調整
      end
      else
        FBufPos := 0; //バッファ内位置初期化
    end;
  end;
  Move(FBuf[FBufPos], Buffer, ForwardSize); //バッファから転送
  Result := ForwardSize;
  Inc(FBufPos, ForwardSize);
  Inc(FFilePos, ForwardSize);
end;

function TBufFileStream.Write(const Buffer; Count: Integer): Longint;
begin
  Result := inherited Write(Buffer, Count);
end;

function TBufFileStream.Seek(Offset: Integer; Origin: Word): Longint;
begin
  case Origin of
    soFromCurrent:
      Result := inherited Seek(FFilePos + Offset, soFromBeginning);
    else
      Result := inherited Seek(Offset, Origin);
  end;
  FBufPos := 0;
  FBufEnd := 0;
  FFilePos := Result;
end;

end.

解説

 既存のTFileStreamとの互換性を重視して作ったので、基本的には置き換えるだけで使えます。Delphi4からの新機能であるデフォルト引数を使ってるので、Delphi4以上が必要ですがコンストラクタのデフォルト引数をやめればDelphi3でも大丈夫です。その場合、TFileStreamとの完全な置換が出来なくなるので、引数を減らしてバッファ長を固定にするか、バッファ長のプロパティを作るなどする必要があるかも知れません。
 実はReadしかバッファリングしてません。Writeの処理は面倒なんで、書いてません(一応スペースだけはある)。んなわけで、主なメソッドは2つ。ReadとSeekです。
 Readはバッファにデータが無ければ、バッファを取得 → バッファから実際に要求してきた部分を転送。という感じです。バッファに収まらない部分の場合は、バッファ自体を先頭にずらしてからファイルからバッファに取得 → 転送。ややこしいのは『ずらす』部分だけです。

 Seekは簡単にやっちゃってます。Seekが発生する度にバッファをリセットしちゃってるわけです。

使い方


procedure TForm1.Button1Click(Sender: TObject);var
  F: TFileStream;
  Buf: array[0..3] of char;
  L: Integer;
  t: Int64;
begin
//  F := TFileStream.Create('c:\test.txt', fmOpenRead);
//            ↓  変数宣言部はTFileStreamのままでOK
  F := TBufFileStream.Create('c:\test.txt', fmOpenRead);
  try
    t := GetTickCount;
    repeat
      L := F.Read(Buf, SizeOf(Buf));
    until L = 0;
    ShowMessage(IntToStr(GetTickCount - t) + ' ms');
  finally
    F.Free;
  end;
end;

 1箇所だけTFileStreamの部分をTBufFileStreamと変えるだけでOKです。変数宣言のは変えても変えなくてもOKです。これはTBufFileStreamの主要なメソッドは全て仮想メソッドであり、VMT(バーチャルメソッドテーブル)によりTBufFileStreamのメソッドがちゃんと呼ばれる為です。これがオブジェクト指向プログラミングにおける多態性(ポリモーフィズム)と言われているものです。これをもって多態性の全てだとは言えませんが、こんなところでも便利ッスね。

効果

 さて実際どのくらい速くなったのでしょう。上のプログラムを実行すれば、読み出しに掛かった時間がメッセージボックスに表示されます。テストに使ったファイルはおよそ18M。バッファサイズは16Kbyteです。OSのディスクキャッシュの影響があるので、1回目の計測は無視して2回目の結果を採用することにします。よって全てキャッシュに入っていると考えて良いと思います。本来なら、ディスクキャッシュに載る前の値を使うべきですが、いちいち再起動するのが面倒だったのでサボってます。あとGetTickCountはあまり正確ではないので、下記の結果はあくまで参考程度に考えてください。(K6-3 400、Maxtor 20G 5400rpm、Windows2000)

読み出し単位(byte) TFileStream(sec) TBufFileStream(sec)
4 18.837 1.422
8 9.584 0.791
16 4.757 0.461
32 2.394 0.310
64 1.242 0.220
128 0.411 0.170
1024 0.200 0.150

 これを見るとTFileStreamのバッファリングは読み出し単位が小さい場合は、かなり効果があることが分かります。つーか、4byte・8byte単位でアクセスしてては遅すぎです。またディスクはメモリに比べるととても遅いので、ディスクキャッシュに載ってない場合を考えると、もっと差があると考えてよさそうです。ただ、今回テストに使ったファイルは18Mと比較的大きなファイルなので、実はあまり現実的ではありません。実際に使う場合は、数k〜数100k程度のファイルを扱う場合が多いと思うので、それを考えるとどちらも一瞬で終わってる可能性もあります。要するに10msの4倍の時間が掛かっても、たかが40msでしかないのです。でもまぁ速いに越したことはないです。
 文字列をStreamに格納する場合は、『文字列の長さ』と『文字列』という2つの情報を格納する必要があります。文字列や数値が大量に格納されている場合などで小さいReadが頻繁に起こる時は、バッファリングして高速化することを考慮する価値はあるといえます。


問題点と対策

 完璧にするには、Writeもバッファを使わないとダメでしょう。が、これが結構面倒なわけです(また『書き』は『読み』に比べると、速度を要求される場面は少ない。『書き』は安定性の方が重要)。いちばん簡単なのは、Write専用のバッファを持つことです。ReadとWriteが頻繁に交互する場合はかなり有効ですが、普通はそんな事は無いので、Write専用バッファはメモリの無駄です(少しだけど)。書き込み専用バッファを持たない場合は、読み・書きでバッファを共用することになります。そうするとバッファの状態を管理する必要があり結構面倒です。また、最後に書き込むタイミングも微妙です。簡単なのは、バッファを強制的に書き込むメソッドを用意して、クラスを使う時に使う側で書き込みの最後にそのメソッドを呼び出せばいいでしょう。ただし、TFileStreamから置換した場合はそのソースも若干変更する必要が生じてしまいます。自動でやるにはタイマーでやります(すげー面倒な感じ)。「デストラクタで呼ぶからいいのだ」という考えもあるかもしれませんが、ちょっと恐いです。
 次にSeek。現状ではSeekの度にリセットしてますが、本当はダメです。ReadしてSeekを繰り返すような処理の場合、TFileStreamよりパフォーマンスが落ちてしまいます。これは、バッファ内のSeekの場合は、バッファ内位置のみ変更して、バッファのリセット・ファイルのSeekは行わないようにしてやる必要があります。面倒なんでやってません。
 最後にバッファ長の問題。バッファを越えるようなサイズのReadが要求された場合、例外を出してます。これはこれでもいいと思いますが、例外を出すのはイヤだという場合は要求を満たすようなサイズにバッファの再割り当てをする必要があります。まぁバッファ長を超えるという状況はおかしい場合が多いので、例外を出す方が無難だと思いますけど。
 最後にバッファサイズですが、最適な値はOSやファイルシステム・クラスタサイズによって変わってきます。あまり大きくしても意味はありませんで、だいたい8k〜16kくらいが良さそうです。この間でほとんど一緒でした。

 意見・バグなどがあったら、伝書鳩にて。