[gypark@www perlstudy_1473]$ ./sort.pl 11
method 1 - in-memory sort. [input_seq.txt]
laptime time after loading file : 1.730617
laptime time after counting : 4.001903
laptime time after sorting : 13.373383
method 1 - save [sorted_1_seq.txt]
total elapsed time : 14.21391
사용메모리: 약 745M
랜덤 파일
[gypark@www perlstudy_1473]$ ./sort.pl 12
method 1 - in-memory sort. [input_rnd.txt]
laptime time after loading file : 1.725655
laptime time after counting : 4.212954
laptime time after sorting : 40.480208
method 1 - save [sorted_1_rnd.txt]
total elapsed time : 41.319748
사용메모리: 약 820M
파일을 읽어 배열에 넣는데 1.7초, 그 배열원소 3백만개를 순회하며 키를 뽑아내어 빈도수를 재는 데 2.3초 정도 걸리는 건 비슷하나, 그 다음 정렬할 때 차이가 많이 난다. 순차 파일의 경우 같은 키에 해당하는 원소들이 이미 정렬된 채로 모여 있어서 그런 듯.
(여담으로, 위 테스트를 처음에는 윈도우7이 깔린 컴퓨터에서 하려고 했는데, 파일을 읽는 데에만 십수분이 걸려도 끝나지 않았다 -_-;; /StringConcatenationBenchmark에서도 얘기가 나왔었지만 윈도우에서 메모리를 realloc 으로 계속 늘려나갈 때 문제가 있어서 그런 게 아닐까 짐작함)
문제는 메모리 사용량이다. 25MB 짜리 텍스트 파일을 읽어 배열에 저장할 때 400MB 이상을 쓰게되고, 정렬을 위해서 배열이 하나 복사되면서 두 배가 된다. 라인 수를 7백만개로 늘렸을 경우 내 테스트 환경에서는 순차 파일은 2GB를 간당간당하게 쓰면서 40초 정도 끝났는데, 랜덤 파일은 디스크에 스왑을 하게되는 듯 끝날 기미가 안 보여서 포기했음.
메모리가 부족해서 통채로 읽을 수 없으니, 할 수 없이 디스크를 쓰기로 한다.
기본 컨셉은, 하나의 키에 해당하는 라인들만 뽑아내어 파일 하나에 저장하는 식으로 분리를 한 후, 빈도수가 높은 (즉 라인수가 많은) 키의 파일들부터 읽어서 하나의 파일에 기록하는 식.
my%freq = ();
openmy$in, '<', $input_file;
while ( my$line = <$in> ) {
my$key = (split /\s+/, $line)[0];
$freq{$key}++;
# 한 라인을 읽을 때마다 매번 그 라인에 해당하는 키 파일을 열고 거기에 덧붙인 후 닫음openmy$tmp, '>>', $key;
print {$tmp} $line;
close$tmp;
}
close$in;
openmy$out, ">", $output_file;
foreachmy$key ( sort { $freq{$b} <=> $freq{$a} or$a <=> $b } keys%freq ) {
# 정렬한 키 순서대로 키 파일을 읽어서 최종 결과물에 덧붙임openmy$tmp, '<', $key;
while ( my$line = <$tmp> ) {
print {$out} $line;
}
close$tmp;
unlink$key;
}
close$out;
실행 결과:
[gypark@www perlstudy_1473]$ ./sort.pl 21
method 2 - open-close in each iteration. [input_seq.txt]
laptime time after loading, counting, splitting : 132.328214
method 2 - save [sorted_2_seq.txt]
total elapsed time : 134.268979
사용메모리: 약 10M
[gypark@www perlstudy_1473]$ ./sort.pl 22
method 2 - open-close in each iteration. [input_rnd.txt]
laptime time after loading, counting, splitting : 1081.486577
method 2 - save [sorted_2_rnd.txt]
total elapsed time : 1089.66655
사용메모리: 약 10M
메모리는, 쓸래야 쓸 일이 없다. 항상 한 라인씩 처리하기 때문에 10MB를 넘지 않는다.
순차 파일과 랜덤 파일 둘 다 정렬하고 최종 파일에 기록하는 건 몇 초 걸리지 않는데, 입력을 읽으며 키 파일들을 만드는 데 시간의 차이가 크게 난다. 어차피 3백만 번 오픈,클로즈를 꼬박꼬박 하는 건 마찬가지인데 이렇게 차이가 나는 건, 순차 파일의 경우는 한번 열었던 파일을 다시 열게 되니까 이 때는 버퍼 캐시 등의 도움으로 오버헤드가 줄어들기 때문으로 생각됨.
3백만 라인을 읽는 동안 매번 파일을 열고,닫는 건 너무 오버헤드가 크다. 이걸 줄여볼 요량으로, 어떤 키가 처음 나타날 때 그 키에 해당하는 파일을 열고, 그 다음부터는 계속 연 채로 두기로 한다.
my%freq = ();
# 각 키에 해당하는 파일 핸들들의 해시my%fh = ();
openmy$in, '<', $input_file;
while ( my$line = <$in> ) {
my$key = (split /\s+/, $line)[0];
$freq{$key}++;
# 어떤 키가 처음 나오면 그 때 열고if ( notexists$fh{$key} ) {
open$fh{$key}, '>', $key;
}
# 열린 파일 핸들을 계속 사용print {$fh{$key}} $line;
}
close$in;
# 나머지는 앞 섹션과 동일
실행 결과:
[gypark@www perlstudy_1473]$ ./sort.pl 31
method 3 - open-close once. [input_seq.txt]
Failed to load 'autodie::exception'.
This may be a typo in the 'autodie->exception_class' method,
or the 'autodie::exception' module may not exist.
Can't locate autodie/exception.pm: Too many open files at (eval 105) line 2, <$_[...]> line 1917511.
at /home/gypark/perl5/perlbrew/perls/perl-5.14.2/lib/5.14.2/Fatal.pm line 1250, <$_[...]> line 1917511.
Fatal::throw('autodie', 'function', 'CORE::open', 'args', 'ARRAY(0xa0997f8)', 'pragma', 'autodie',
'errno', 'Too many open files', ...) called at (eval 19) line 114
main::__ANON__('GLOB(0xa0997b8)', '>', 1020) called at ./sort.pl line 108
안타깝게도... 시스템에서 한 프로세스가 동시에 열 수 있는 파일의 갯수에는 제한이 있다. 이 시스템에서는 1024로 설정되어 있고, 수정하려면 /etc/security/limits.conf에서 해 줄 수 있긴 하다. 윈도우에서는 2048개 정도인 듯 한데 수정하는 방법을 알아내지 못 했다. (구글링해보면 말이 다 다름)
한군님이 데이타 사이즈를 줄여서 테스트한 게 있는데, 거기에서는 매번 오픈-클로즈 할 때에 비해 속도가 절반으로 줄어들었다고 한다.
그러면 현재 열려 있는 파일 핸들의 갯수를 계속 주시하다가 한계를 넘을 것 같으면 기존의 핸들을 닫아야 한다. 이걸 자동으로 해 주는 게 이 FileCache 모듈이다. 이걸 사용해 보았다.
# 이 모듈은 심볼릭 레퍼런스 기능을 쓰기 때문에 strict 에서 제외시켜야 한다.no strict'refs';
my%freq = ();
my%fh = ();
openmy$in, '<', $input_file;
while ( my$line = <$in> ) {
my$key = (split /\s+/, $line)[0];
$freq{$key}++;
# print 하기 전에 cacheout 을 불러 주면# 핸들이 없다면 생성해주고, 있으면 그냥 반환해주고, 닫혔다면 다시 열어서 반환해준다.# 그리고 파일 이름을 "키번호.txt"의 형식으로 짓게 했는데, 그냥 "키번호"로 이름을 지었더니# 키가 0인 경우 이게 표준입력의 핸들과 같은 수치가 되어 버려서 그런지 에러가 나더라.$fh{$key} = cacheout "$key.txt";
print {$fh{$key}} $line;
}
close$in;
# 그냥 close 를 해도 될 것 같긴 한데, 모듈 문서에서 뭐라뭐라 하길래 거기서 권유하는 cacheout_close 를 사용# 처음에는 close를 하지 않았다가 큰 낭패를 봤다. 본문에서 자세히 얘기함
cacheout_close $_foreach ( values%fh );
# 병합 과정은 앞에서와 동일
순차 파일
[gypark@www perlstudy_1473]$ ./sort.pl 41
method 4 - use cacheout. [input_seq.txt]
laptime time after loading, counting, splitting : 17.825475
method 4 - save [sorted_4_seq.txt]
total elapsed time : 19.576668
랜덤 파일
[gypark@www perlstudy_1473]$ ./sort.pl 42
method 4 - use cacheout. [input_rnd.txt]
laptime time after loading, counting, splitting : 891.26132
method 4 - save [sorted_41_rnd.txt]
total elapsed time : 893.901962
순차 파일은 매우 빠르다! 메모리에 전부 불러오는 경우와 비교해도 몇 초 차이나지 않는다. 게다가 8573개의 파일을 여는 데에도 전혀 문제가 없다. 메모리 사용량은 13MB 정도였다.
그에 비해 라인이 랜덤하게 섞여 있을 경우는 크게 느려진다. 매번 오픈하는 두번째 경우와 비교할 때 1081초에서 891초로 20% 정도 줄기는 했으나 그래도 꽤나 걸린다.
순차 파일은 한 번 나왔던 키는 연달아 나온 후 더 이상 나오지 않으니까, 다른 파일을 열기 위해서 기존의 핸들을 닫고 나면 다시 열 일이 없다. 즉 기존의 핸들을 닫고 새 핸들을 여는 걸 키의 갯수만큼만 수행하면 된다. (최대 8573번이고, 실제론 동시에 여러 핸들을 열 수 있으니 이보다 줄어들 것이다) 그러나 랜덤 파일은 아까 닫았던 핸들을 다시 열어야 되는 경우가 계속 생기니까, 장기적으로는 매번 open-close 하는 것과 비슷해질 것이다. 따라서 이 결과는 납득할 수 있다.
어쨌거나 매번 오픈하는 것에 비해 시간을 단축할 수 있다.
여기서 내가 테스트 중에 상당히 곤혹스러운 일이 있었는데, 처음 시도할 때는, 분할이 끝난 직후에 키 파일들에 대한 파일 핸들을 닫지 않았다. FileCache가 관리하는 핸들을 수동으로 close해도 괜찮을까 걱정이 되었고, 모듈 문서에서 "가능하긴 하지만 어쩌고 저쩌고"하길래 그랬던 것이다. 그랬더니만 정렬된 결과 파일이 앞의 방법들과 비교해서 달라지는 문제가 생겼다.
순차 파일을 정렬했을 경우, 일부 키들이 통채로 사라져버렸다
랜덤 파일을 정렬했을 경우, 일부 키들의 마지막 라인이 사라졌다
분할 직후에 일시정지하도록 수정한 후, 생성된 키 파일들을 봤더니 크기가 0이었다. 즉 print 가 제대로 되지 않은 것이다. 그리고 이렇게 크기 0인 파일을 병합하게 되니, 최종 결과에 그 키에 해당하는 라인들이 나타나지 않는다.
상당히 당혹스러운 현상이고 원인도 알 수 없어서 한참을 고민했다. 그런데 병합이 끝나고 프로그램이 종료된 다음에 크기가 0인 파일들을 다시 확인하려 했더니, 그 파일들이 그제서야 제대로 저장이 되어 있더라. 그제서야 이게 print 자체는 수행이 되었는데 그게 디스크에 반영이 늦게 되는 문제란 걸 알았다. 디스크에 반영이 안 된 상태로 병합 루프에서 직접 open을 하여 읽으니 읽을 데이타가 없는 것이다. (이런 경우 OS 차원에서 동기화해줘야 되는 것 같기도 한데?)
그래서 부랴부랴 cacheout_close()를 써서 핸들을 전부 닫고 병합을 진행하도록 하였다. 그러고나니 결과가 제대로 나왔다.
# 해시에 핸들이 존재하지 않을 경우에는 cacheout을 써서 받아옴if ( notexists$fh{$key} ) {
$fh{$key} = cacheout "$key.txt";
}
print {$fh{$key}} $line;
얼핏 보면 두 방법의 장점만 취합한 좋은 방법 같지만, 함정이 있다. 오픈 파일의 갯수가 한계에 걸려서 기존 핸들의 일부를 FileCache모듈이 닫아버렸을 때, 여전히 저 %fh 해시에는 그 키가 남아 있다. 따라서 exists 검사는 통과하지만, 실제 핸들은 닫혀 있는 상태에서 print 를 시도할 것이다.
실제 결과를 보면,
순차 파일
[gypark@www perlstudy_1473]$ ./sort.pl 51
method 5 - use cacheout (maybe error with random sequence). [input_seq.txt]
laptime time after loading, counting, splitting : 5.53795
method 5 - save [sorted_5_seq.txt]
total elapsed time : 7.270632
순차 파일은 아주 빠르다! 매번 cacheout 검사를 하지 않고 필요할 때만 부르니 훨씬 빨라진다.
그러나 랜덤하게 섞인 데이타를 가지고 수행하면:
랜덤 파일
[gypark@www perlstudy_1473]$ ./sort.pl 52
print() on closed filehandle 1828.txt at ./sort.pl line 185, <$_[...]> line 157.
print() on closed filehandle 8.txt at ./sort.pl line 185, <$_[...]> line 161.
print() on closed filehandle 294.txt at ./sort.pl line 185, <$_[...]> line 184.
...
이렇게 무수히 많은 경고가 뜬다. 이미 닫힌 핸들에 대해 print를 시도하기 때문이다. 예상한 결과다. 어째서 순차 파일에는 이런 경고가 뜨지 않는가? 그것도 충분히 짐작 가능하지만, 한번 닫은 핸들에 다시 기록을 시도할 일이 없기 때문이다.
저 경고를 무시하고 끝까지 진행하면 어떻게 될까? 혹시 경고는 뜨지만 실제 write는 제대로 되지 않을까 하는 생각에 warnings 프라그마를 끄고 실행해보았더니, 실행은 순차파일때와 동일하게 7초여만에 끝났지만, 정렬결과는... 3백만 라인 중 9453라인만 남아 있고 나머지는 다 사라져 버렸다.
혹시나 싶어서 exists 대신에 defined로 검사를 해보기도 했다. 전혀 소용없었다.(FileCache 모듈이 어떤 핸들을 닫을 경우 저 변수값도 undef으로 바꿔주지 않을까 한 것인데, 사실 말이 안 되는게 fh 해시에 들어있는 값은 레퍼런스도 아니고 단순한 스트링이다. 그러니 그 자체가 바뀔 리가 없다)
검사를 tell($fh{$key}) == -1과 같이 해보기도 했다. tell은 닫힌 파일핸들에 대해 수행하면 경고를 띄우면서 -1을 반환하는 것을 이용한 것이다. 즉 핸들이 닫혔는지를 모듈에 맡기지 않고 수동으로 검사해주는 것인데, 정렬 결과는 제대로 나왔으나 시간은 더 오래 걸렸다. (947초)
그러니 그냥 핸들을 사용하기 전에 cacheout 을 꼬박꼬박 불러주는 것이 제일 낫다고 생각된다. 물론 동일한 핸들에 연달아 기록을 할 때 매번 불러줄 필요는 없겠고, 다른 핸들을 한참 사용하다가 이 핸들을 간만에 사용하게 되는 시점에 한번씩 불러주면 될 것이다.