ファイルの最後の方に出現する行を取得する

大量のファイル、しかもサイズの大きいものを読み込んで、ある特定の小数の行だけを取り出したいような場合で、その特定の行がいつもファイルの最後に近い位置に出現することが期待できる場合、ファイルを先頭から読み込んで行って、特定の行に達するまで読み捨てていく方法では時間がかかるのは明白だ。seekを使って、最後の方だけを読み込むことで、最小限のファイルシーク時間で処理を済ませたい。最後の方でなくても、ファイル中のどの位置にあるかがある程度分かっていればseekで読み飛ばせば良い。

気をつけないといけないのは、ファイルの先頭から最後まで読み込んでいく場合は、その特定の行にいつか辿り着くはずであるが、ファイルの後方からあるサイズだけ戻ってから読み込む場合は、そのサイズよりも前にその特定の行が出現している場合がある。ファイルの後方から戻るサイズは最小限にしておき、その特定の行をミスした場合はそのサイズを倍にしていく。(特定の行が存在しない場合に備え、そのサイズをファイルサイズと比較して処理を抜ける必要がある)あと、すでに読み込んで検査した行を、サイズを大きくする度に重複して何度も検査してしまっているので、読み込んだサイズを確認して一度読み込んで検査した行に達した場合は、処理を抜けるようにする必要がある(ToDo)。あと、読み込んだブロックの最初と、次に読み込んだブロックの最後にまたがって、特定の行が出現する場合に対処する必要がある(ToDo)。

以下、簡単にベンチマーク。ファイルの後方から読み込む場合はあえて一回で特定の行が見つからないように、サイズを調整している。(実際には、同じファイルを何度も参照しているので、読み込む度にメモリのディスクキャッシュをpurgeするか、別のファイルを100個用意する必要があるが、今回は省略)

import os, time, sys

start = time.time()
for i in range(0,100):
    for l in open("log.txt").readlines():
        if l.rstrip() == "9990":
            sys.stdout.write("."),
            sys.stdout.flush()
            break
print ""
end = time.time()
print end-start

start = time.time()
for i in range(0,100):
    flag = True
    filesize = os.path.getsize("log.txt")
    f = open("log.txt")
    seek_size = -20
    while flag:
        f.seek(seek_size,os.SEEK_END)
        seeked_size = 0
        for l in f.read().split("\n"):
            if l.rstrip() == "9990":
                sys.stdout.write("."),
                sys.stdout.flush()
                flag = False
                break
            seeked_size += len(l)+1
            if seeked_size > seek_size / 2 * (-1):
                break
        seek_size *= 2
        if flag:
            sys.stdout.write("-"),
            sys.stdout.flush()
        if seek_size * (-1) > filesize:
            break
    f.close()
print ""
 
end = time.time()
print end-start
$ for i in $(seq 1 10000);do echo $i >> log.txt; done

$ wc -l log.txt 
   10000 log.txt

$ python --version
Python 2.7.10

$ python large_file_search.py 
....................................................................................................
0.270761966705
--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.--.
0.00701999664307

Python3では

上記はPython2での結果。Python3では以下のようにエラーとなる。Documentには、textで読み込んだファイルに対して、io.SEEK_ENDからシークすることはできない、とある。

$ python --version
Python 3.4.3

$ python large_file_search.py 
....................................................................................................
0.31100988388061523
Traceback (most recent call last):
  File "large_file_search.py", line 21, in <module>
    f.seek(seek_size,os.SEEK_END)
io.UnsupportedOperation: can't do nonzero end-relative seeks

7. Input and Output — Python v3.2.6 documentation

In text files (those opened without a b in the mode string), only seeks relative to the beginning of the file are allowed (the exception being seeking to the very file end with seek(0, 2)).

そのため、os.path.getsizeでファイルの全体サイズを取得したうえで、ファイルの先頭位置から所望の位置までio.SEEK_SETからシークすれば良い。

Perlでは

こんな感じ。SEEK_ENDから負のオフセット値を指定できる。

use strict;
use Time::HiRes qw( gettimeofday tv_interval );
       
my $fsize = -s "log.txt";
       
my $stime = [gettimeofday];
for(1..100){
    open(IN,"log.txt");
    while(<IN>){
        if( $_ == "9990" ){
            print ".";
            last;
        }   
    }  
    close(IN);
}   
my $elapsed = tv_interval($stime);
       
print "\n".int($elapsed * 1000)."msec\n";
       
my $stime = [gettimeofday];
for(1..100){
    open(IN,"log.txt");
    seek(IN,-200,2);
    while(<IN>){
        if( $_ == "9990" ){
            print ".";                                                                               
            last;
        }   
    }  
    close(IN);
}   
my $elapsed = tv_interval($stime);
       
print "\n".int($elapsed * 1000)."msec\n";
$ perl --version

This is perl 5, version 18, subversion 2 (v5.18.2) built for darwin-thread-multi-2level
(with 2 registered patches, see perl -V for more detail)

Copyright 1987-2013, Larry Wall

Perl may be copied only under the terms of either the Artistic License or the
GNU General Public License, which may be found in the Perl 5 source kit.

Complete documentation for Perl, including FAQ lists, should be found on
this system using "man perl" or "perldoc perl".  If you have access to the
Internet, point your browser at http://www.perl.org/, the Perl Home Page.

$ perl file_test.pl 
....................................................................................................
173msec
....................................................................................................
1msec