Эффективное решение для кэширования строк, извлеченных из большого количества текстовых файлов. - PullRequest
4 голосов
/ 03 апреля 2020

Для набора текстовых файлов (все очень маленькие с ~ 100 строками) в каталоге мне нужно построить некоторую строку и затем передать все в fzf, чтобы пользователь мог выбрать один файл. Сама строка зависит от первых нескольких (~ 20) строк файла и строится с использованием пары очень простых шаблонов регулярных выражений. Ожидается, что между последовательными вызовами будут изменены только несколько файлов. Я ищу способ сделать это без заметной задержки (для пользователя) для файлов размером около 50 тыс.

Вот что я сделал до сих пор: Моим первым решением для этого было Наивный сценарий оболочки, а именно:

cat $dir/**/* | $process_script | fzf

, где $ process_script - это некоторый Perl сценарий, который читает каждый текстовый файл построчно, пока не соберет требуемую строку, а затем напечатает ее. Уже имея 1000 файлов для обработки, этот сценарий больше не может использоваться, так как он занимает около двух секунд и, следовательно, вызывает заметную задержку для пользователя. Поэтому я реализовал кеш бедного человека, сохранив строки в каком-то текстовом файле, а затем обновил только те строки, которые фактически изменились (на основе mtime файлов). Новый скрипт примерно выполняет:

$find_files_with_mtime_newer_than_last_script_run | $process_script | fzf

, где $ find_files_with_mtime_newer_than_last_script_run запускает fd (быстрая замена поиска), а $ process_script - это Perl скрипт вида

my $cache = slurp($cachefile); #read lines of cachefile into multiline string
my ($string,$id);

while (<>) {

      ($string, $id) = build_string($_); #open file and build string

      $cache = s/^.*$id.*\n//; #delete old string from cache

      $cache = $cache . $string; #insert updated string into cache

}

print $cache;

spew($cache, $cachefile); #write cachefile

spew(printf('%s', time),$mtimefile); #store current mtime

Здесь slurp, spew и build_string делают то, что написано в комментариях. Сейчас это решение достаточно быстрое, чтобы пользователь не заметил никакой задержки, но я подозреваю, что это снова изменится, когда число файлов возрастет.

Мой вопрос Как написано выше, я ищу способ ускорить выполнение этой задачи. В частности, не могли бы вы прокомментировать, если следующая стратегия должна привести к приемлемому (то есть менее чем за секунду) времени выполнения:

Замените файл кэша простого текста на файл SQLite (или что-то подобное), который сохраняет встроенную строку вместе с соответствующим именем файла и временем его последней обработки, затем передает текущее время в сценарий, извлекает все файлы, которые необходимо обновить непосредственно из SQLite, без использования find или fd и распараллеливает обработку для этих файлов которые должны быть обновлены с использованием параллельной GNU.

Конечно, я также был бы очень благодарен за различные решения.

Ответы [ 2 ]

5 голосов
/ 03 апреля 2020

Примечание Первая часть имеет подход с использованием файла кэша, вторая - подход с sqlite, а затем проводится сравнение между ними.


Будет ли какое-то одно решение достаточно быстрым для этой цели, конечно, полностью зависит от всех этих чисел. Так же как и лучший подход.

Для того, что вы показываете - крошечные файлы, из которых очень мало изменений - основы должны быть достаточно хороши

use warnings;
use strict;
use feature 'say';

my $fcache = 'cache.txt';  # format: filename,epoch,processed_string

open my $fh, '<', $fcache or die "Can't open $fcache: $!";
my %cache = map { chomp; my @f = split /,/, $_, 3;  shift @f => \@f } <$fh>; #/
close $fh;

for (@ARGV) {
    my $mtime = (stat)[9];

    # Have to process the file (and update its record)
    if ( $cache{$_}->[0] < $mtime ) { 
        @{$cache{$_}} = ($mtime, proc_file($_));
    }   

    say $cache{$_}->[1];
}

# Update the cache file
open my $fh_out, '>', $fcache or die "Can't open $fcache: $!";
say $fh_out join(',', $_, @{$cache{$_}}) for keys %cache;

sub proc_file {  # token processing: join words with _
    my $content = do { local (@ARGV, $/) = $_[0]; <> };
    return join '_', split ' ', $content;
}

Примечания

  • Это не сохранит порядок записей в кэше, так как используется ха sh, что, похоже, не имеет значения. Если это необходимо, вам нужно знать (записать) существующий порядок строк, а затем отсортировать их так, прежде чем писать

  • Выбор точной структуры файла «кеша» и используемой в программе структуры данных несколько произвольны, как образцы. Улучшите это, во что бы то ни стало

  • Для работы вышеупомянутого уже должен существовать файл кэша в формате, указанном в комментарии: filename,seconds-since-epoch,string. Добавьте код, чтобы написать его, если он не существует

  • Самым крупным потребителем здесь является строка, заполняющая сложную структуру данных из файла размером в 50 КБ. Это должно оставаться самой трудоемкой частью, пока файлы малы, и только немногие нуждаются в обработке

Я бы сказал, что использование sqlite в большинстве случаев добавит накладные расходы для такой небольшой проблемы. ,

Если число файлов, обрабатываемых каждый раз, выходит за рамки небольшого количества, то вы можете попробовать его параллельно - учитывая, как мало они составляют большую часть времени, затрачиваемого на накладные расходы при доступе к файлам, и, возможно, есть там достаточно "локтевой комнаты", чтобы выиграть от параллельной обработки. Кроме того, в общем случае ввод / вывод, безусловно, может быть ускорен при параллельной работе, но это полностью зависит от обстоятельств.


Я подумал, что это идеальный случай для сравнения с sqlite, поскольку я ' Я не уверен, чего ожидать.

Во-первых, я записываю 50000 крошечных файлов (a N b) в отдельный каталог (dir)

perl -wE'for (1..50_000) { open $fh, ">dir/f$_.txt"; say $fh "a $_ b" }'

(всегда используйте три аргумента open нормально!) На моем старом ноутбуке это заняло 3 секунды.

Теперь нам нужно создать файл кэша и базу данных (sqlite) с этими файлами, затем обновить их и сравнить обработка с использованием программ с sqlite и с файлом кэша.

Вот первый код для подхода с использованием sqlite.

Создание и заполнение базы данных в файле files.db

use warnings;
use strict;
use feature 'say';    
use DBI;

my ($dir, $db) = ('dir', 'files.db');
my $dbh = DBI->connect("DBI:SQLite:dbname=$db", '', '', { RaiseError => 1 });

my $table = 'files';
my $qry = qq( create table $table (
    fname   text     not null unique,
    mtime   integer  not null,
    string  text
); );
my $rv = $dbh->do($qry);

chdir $dir or die "Can't chdir to $dir: $!";    
my @fnames = glob "*.txt";

# My sqlite doesn't accept much past 500 rows in single insert (?)
# The "string" that each file is digested into: join words with _
my $tot_inserted = 0;
while (my @part = splice @fnames, 0, 500) {
    my @vals;
    for my $fname ( @part ) { 
        my $str = join '_', 
            split ' ', do { local (@ARGV, $/) = $fname; <> };
        push @vals, "('$fname'," . (stat $fname)[9] . ",'$str')";
    }   
    my $qry = qq(insert into $table (fname,mtime,string) values ) 
            . join ',', @vals;

    $tot_inserted += $dbh->do($qry);
}
say "Inserted $tot_inserted rows";

Это заняло около 13 секунд, одноразовые расходы. Я insert 500 строк за раз, так как мой sqlite не позволит мне сделать намного больше; Я не знаю, почему это так (я поместил PostgreSQL в несколько миллионов строк в одном операторе вставки). При наличии ограничения unique для столбца он индексируется .

Теперь мы можем изменить несколько временных отметок

touch dir/f[1-9]11.txt

, а затем запустить программу для обновления sqlite база данных для этих изменений

use warnings;
use strict;
use feature 'say';    
use DBI;    
use Cwd qw();
use Time::HiRes qw(gettimeofday tv_interval);

my $time_beg = [gettimeofday];

my ($dir, $db) = ('dir', 'files.db');
die "No database $db found\n" if not -f $db;    
my $dbh = DBI->connect("DBI:SQLite:dbname=$db", '', '', { RaiseError => 1 });

# Get all filenames with their timestamps (seconds since epoch)
my $orig_dir = Cwd::cwd;
chdir $dir or die "Can't chdir to $dir: $!";
my %file_ts = map { $_ => (stat)[9] } glob "*.txt";

# Get all records from the database and extract those with old timestamps    
my $table = 'files';
my $qry = qq(select fname,mtime,string from $table);    
my $rows = $dbh->selectall_arrayref($qry);
my @new_rows = grep { $_->[1] < $file_ts{$_->[0]} } @$rows;
say "Got ", 0+@$rows, " records, ", 0+@new_rows, " with new timestamps";

# Reprocess the updated files and update the record
foreach my $row (@new_rows) { 
    @$row[1,2] = ( $file_ts{$row->[0]}, proc_file($row->[0]) );
}

printf "Runtime so far: %.2f seconds\n", tv_interval($time_beg);  #--> 0.34

my $tot_updated = 0;
$qry = qq(update $table set mtime=?,string=? where fname=?);
my $sth = $dbh->prepare($qry);
foreach my $row (@new_rows) {
    $tot_updated += $sth->execute($sth);
}
say "Updated $tot_updated rows";

$dbh->disconnect;
printf "Runtime: %.2f seconds\n", tv_interval($time_beg);  #--> 1.54

sub proc_file {
    return join '_',
        split ' ', do { local (@ARGV, $/) = $_[0]; <> };
}

Это явно не печатается. Я оставил это, поскольку есть несколько способов сделать это, хотя я не был уверен, что именно нужно печатать. Я бы, вероятно, запустил еще один select для этой цели, после того как все будет обновлено.

Программа занимает удивительно стабильно около 1,35 секунды в среднем за несколько прогонов. Но до той части, где он update - база данных для этих (немногих!) Изменений, это занимает около 0,35 секунды, и я не понимаю, почему update из нескольких записей занимает столько времени по сравнению.

Далее, чтобы сравнить, нам нужно завершить подход , используя файл кэша из первой части этой позиции, записав этот файл кэша (то, что там осталось). Полная программа очень немного отличается от первой

use warnings;
use strict;
use feature 'say';    
use Cwd qw();

my ($dir, $cache) = ('dir', 'cache.txt');
if (not -f $cache) { 
    open my $fh, '>', $cache or die "Can't open $cache: $!";
    chdir $dir or die "Can't chdir to $dir: $!";
    my @fnames = glob "*.txt"; 
    for my $fname (@fnames) { 
        say $fh join ',', $fname, (stat $fname)[9],
            join '_', split ' ', do { local (@ARGV, $/) = $fname; <> };
    }
    say "Wrote cache file $cache, exiting.";
    exit;
}

open my $fh, '<', $cache or die "Can't open $cache $!";
my %fname = map { chomp; my @f = split /,/,$_,3; shift @f => \@f } <$fh>; #/

my $orig_dir = Cwd::cwd;
chdir $dir or die "Can't chdir to $dir: $!";
my @fnames = glob "*.txt";

for my $f (@fnames) {
    my $mtime = (stat $f)[9];

    # Have to process the file (and update its record)
    if ( $fname{$f}->[0] < $mtime ) { 
        @{$fname{$f}} = ($mtime, proc_file($f));
        say "Processed $f, updated with: @{$fname{$f}}";
    }   

    #say $fname{$_}->[1];  # 50k files! suppressed for feasible testing
}

# Update the cache
chdir $orig_dir  or die "Can't chdir to $orig_dir: $!";
open my $fh_out, '>', $cache or die "Can't open $cache: $!";
say $fh_out join(',', $_, @{$fname{$_}}) for keys %fname;


sub proc_file {
    return join '_', 
        split ' ', do { local (@ARGV, $/) = $_[0]; <> };
}

Написание кеша изначально занимает около 1 секунды. После того, как несколько файлов touch -ed, как в тесте sqlite, следующий запуск этой программы, опять же довольно последовательно, занимает около 0,45 секунды.

С этими тестами я должен сделать вывод, что подход sqlite немного медленнее для этих условий. Тем не менее, это, безусловно, гораздо более масштабируемо, в то время как проекты только имеют тенденцию расти в размере. Напомним также, что update базы данных занимает совсем немного (относительно), что меня удивляет; возможно, что-то не так с моим кодом, и возможно ускорить это.

1 голос
/ 03 апреля 2020

Чтобы ответить на ваши вопросы так, как я ожидал, это:

Замените файл кэша простого текста на файл sqlite (или что-то подобное), в котором хранится собранная строка вместе с соответствующим именем файла, и его последнее время обработки

Да, это ускорит процесс. Затраты на использование DBI и DBD :: SQLite (и ОТКРЫТИЕ файла) на моей машине составляют менее 10 мс.

, затем передайте текущее время скрипту, извлеките все файлы, которые необходимо обновить напрямую из sqlite, без использования find или fd

yes - это можно сделать с помощью одного выделения в индексированном столбце.

и распараллелить обработку для тех файлов, которые необходимо обновить, используя параллель gnu.

Здесь я сомневаюсь в этом. Я бы предположил, что общий ограничивающий фактор будет IO. Так что распараллеливание процесса не поможет.

Самая интересная часть здесь - это использование весов SQLite. Не имеет значения (для обрабатывающей части), содержит ли кэш 1000 или 100000 файлов, изменились только 10 или 1000 файлов.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...