Вопрос
Предположим, что у вас есть большой текстовый файл ASCII со случайным неотрицательным целым числом в каждой строке, каждый в диапазоне от 0 до 1 000 000 000. В файле имеется 100 000 000 строк. Какой самый быстрый способ прочитать файл и вычислить сумму всех целых чисел?
Ограничение: у нас есть 10 МБ ОЗУ для работы. Файл имеет размер 1 ГБ, поэтому мы не хотим читать все это и обрабатывать его.
Вот несколько решений, которые я пробовал. Я нашел результаты довольно неожиданными.
Есть ли что-то быстрее, чем я пропустил?
Обратите внимание: все тайминги, приведенные ниже, предназначены для запуска алгоритма 10 раз в целом (запускать один раз и отбрасывать, запускать таймер, запускать 10 раз, таймер остановки). Машина довольно медленная Core 2 Duo.
Метод 1: естественный подход
Первое, что нужно попробовать, - это очевидный подход:
private long sumLineByLine() throws NumberFormatException, IOException {
BufferedReader br = new BufferedReader(new FileReader(file));
String line;
long total = 0;
while ((line = br.readLine()) != null) {
int k = Integer.parseInt(line);
total += k;
}
br.close();
return total;
}
Обратите внимание, что максимально возможное возвращаемое значение равно 10 ^ 17, которое все еще легко вписывается в long
, поэтому нам не нужно беспокоиться о переполнениях.
На моей машине запуск этого 11 раз и дисконтирование первого запуска занимает около 92,9 секунды.
Способ 2: незначительная настройка
Вдохновленный комментарием этот вопрос, я попробовал не создавать новый int k
для хранения результата разбора строки, а вместо этого просто добавить синтаксический анализ прямо на total
. Итак:
while ((line = br.readLine()) != null) {
int k = Integer.parseInt(line);
total += k;
}
становится следующим:
while ((line = br.readLine()) != null)
total += Integer.parseInt(line);
Я был уверен, что это не будет иметь никакого значения, и подумал, что очень вероятно, что компилятор будет генерировать один и тот же байт-код для двух версий. Но, к моему удивлению, он немного побрился: мы до 92,1 секунды.
Способ 3: ручное разбор целого числа
Одна вещь, которая беспокоит меня по поводу кода, заключается в том, что мы превращаем String
в int
, а затем добавляем его в конец. Не может быть проще добавить, когда мы идем? Что произойдет, если мы сами проанализируем String
? Что-то вроде этого...
private long sumLineByLineManualParse() throws NumberFormatException,
IOException {
BufferedReader br = new BufferedReader(new FileReader(file));
String line;
long total = 0;
while ((line = br.readLine()) != null) {
char chs[] = line.toCharArray();
int mul = 1;
for (int i = chs.length - 1; i >= 0; i--) {
char c = chs[i];
switch (c) {
case '0':
break;
case '1':
total += mul;
break;
case '2':
total += (mul << 1);
break;
case '4':
total += (mul << 2);
break;
case '8':
total += (mul << 3);
break;
default:
total += (mul*((byte) c - (byte) ('0')));
}
mul*=10;
}
}
br.close();
return total;
}
Это, я думал, может сэкономить немного времени, особенно с некоторыми оптимизациями бит-брейка для выполнения умножения. Но накладные расходы на преобразование в массив символов должны увеличивать прибыль: теперь требуется 148,2 секунды.
Способ 4: обработка в двоичном формате
Последнее, что мы можем попробовать - это обработать файл как двоичные данные.
Разбор целого числа с фронта неудобен, если вы не знаете его длины. Разборки в обратном направлении намного проще: первая цифра, с которой вы сталкиваетесь, - это единицы, а следующая - десятки и так далее. Таким образом, самый простой способ приблизиться ко всему этому - прочитать файл назад.
Если мы выделяем буфер byte[]
(скажем) 8 МБ, мы можем заполнить его последним 8 МБ файла, обработать его, затем прочитать предыдущие 8 МБ и т.д. Нам нужно быть немного осторожным, чтобы мы не испортили число, которое мы находимся в середине разбора, когда переходим к следующему блоку, но это единственная проблема.
Когда мы сталкиваемся с цифрой, добавим ее (соответственно умножить в соответствии с ее положением в цифре) на общую сумму, а затем умножим коэффициент на 10, чтобы мы были готовы к следующей цифре. Если мы сталкиваемся с чем-либо, что не является цифрой (CR или LF), мы просто reset коэффициент.
private long sumBinary() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
int lastRead = (int) raf.length();
byte buf[] = new byte[8*1024*1024];
int mul = 1;
long total = 0;
while (lastRead>0) {
int len = Math.min(buf.length, lastRead);
raf.seek(lastRead-len);
raf.readFully(buf, 0, len);
lastRead-=len;
for (int i=len-1; i>=0; i--) {
//48 is '0' and 57 is '9'
if ((buf[i]>=48) && (buf[i]<=57)) {
total+=mul*(buf[i]-48);
mul*=10;
} else
mul=1;
}
}
raf.close();
return total;
}
Это работает в 30,8 секунд! То, что скорость возрастает в 3 раза по сравнению с предыдущим.
Последующие вопросы
- Почему это так быстрее? Я ожидал, что он победит, но не настолько впечатляюще. Это главным образом накладные расходы на преобразование в
String
? И все беспокойство за кулисами о наборах символов и тому подобное? - Можем ли мы сделать это лучше, используя
MappedByteBuffer
, чтобы помочь? У меня такое ощущение, что накладные расходы при вызове методов для чтения из буфера замедлят работу, особенно при чтении назад из буфера. - Было бы лучше читать файл вперед, а не назад, но все же сканировать буфер назад? Идея состоит в том, что вы читаете первый фрагмент файла, а затем сканируете назад, но отбрасываете половину номера в конце. Затем, когда вы читаете следующий фрагмент, вы устанавливаете смещение так, чтобы вы читали с начала номера, который вы отбрасывали.
- Есть ли что-нибудь, о чем я не думал, что может иметь существенное значение?
Обновление: более неожиданные результаты
Во-первых, наблюдение. Это должно было произойти раньше, но я думаю, что причина неэффективности чтения String
- это не столько время, чтобы создать все объекты String
, но и тот факт, что они настолько недолговечны: у нас есть 100 000 000 из них для сборщика мусора. Это должно нарушить его.
Теперь некоторые эксперименты, основанные на ответах/комментариях, опубликованных людьми.
Я обманываю с размером буфера?
Одно из предложений заключалось в том, что поскольку BufferedReader
использует буфер по умолчанию 16 Кбайт, и я использовал буфер размером 8 МБ, я не сравниваюсь с подобным. Он должен быть быстрее, если вы используете больший буфер.
Вот удар. Метод sumBinary()
(метод 4) вчера заработал за 30,8 секунды с буфером 8 МБ. Сегодня код не изменился, направление ветра изменилось, и мы находимся на 30,4 секунды. Если я сброшу размер буфера до 16 КБ, чтобы увидеть, насколько он медленнее, он становится быстрее! Теперь он работает в 23,7 секунды. Псих. Кто видел, что кто-то пришел?!
Немного экспериментов предполагает, что 16 КБ оптимально. Возможно, ребята из Java сделали те же эксперименты, и почему они пошли с 16 КБ!
Является ли проблема связью ввода/вывода?
Я тоже подумал об этом. Сколько времени тратится на доступ к диску и сколько на хруст числа? Если это почти весь доступ к диску, как было предложено хорошо поддержанным комментарием к одному из предложенных ответов, то мы не сможем сделать много улучшения, что бы мы ни делали.
Это легко проверить, запустив код, когда все синтаксические разборки и хруст числа комментируются, но при этом чтение все еще не повреждено:
private long sumBinary() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
int lastRead = (int) raf.length();
byte buf[] = new byte[16 * 1024];
int mul = 1;
long total = 0;
while (lastRead > 0) {
int len = Math.min(buf.length, lastRead);
raf.seek(lastRead - len);
raf.readFully(buf, 0, len);
lastRead -= len;
/*for (int i = len - 1; i >= 0; i--) {
if ((buf[i] >= 48) && (buf[i] <= 57)) {
total += mul * (buf[i] - 48);
mul *= 10;
} else
mul = 1;
}*/
}
raf.close();
return total;
}
Теперь это выполняется в 3,7 секунды! Это не выглядит привязанным к I/O.
Конечно, некоторая скорость ввода-вывода будет поступать из обращений в кеш диска. Но на самом деле это не так: мы все еще занимаем 20 секунд времени процессора (также подтверждается с помощью команды Linux time
), которая достаточно велика, чтобы попытаться уменьшить ее.
Сканирование вперед, а не назад
Я сохранил в своем первоначальном сообщении, что есть веская причина для сканирования файла назад, а не вперед. Я не очень хорошо это объяснил. Идея заключалась в том, что если вы сканируете номер вперед, вам нужно скопировать общее значение отсканированного номера и затем добавить его. Если вы сканируете назад, вы можете добавить его к совокупной сумме по ходу дела. Мое подсознание делало для себя какой-то смысл (на котором позже), но я пропустил один ключевой момент, который был указан в одном из ответов: для сканирования назад я делал два умножения на итерацию, но с сканирование вперед требует только одного. Поэтому я закодировал версию форвардного сканирования:
private long sumBinaryForward() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
int fileLength = (int) raf.length();
byte buf[] = new byte[16 * 1024];
int acc = 0;
long total = 0;
int read = 0;
while (read < fileLength) {
int len = Math.min(buf.length, fileLength - read);
raf.readFully(buf, 0, len);
read += len;
for (int i = 0; i < len; i++) {
if ((buf[i] >= 48) && (buf[i] <= 57))
acc = acc * 10 + buf[i] - 48;
else {
total += acc;
acc = 0;
}
}
}
raf.close();
return total;
}
Это выполняется в 20.0 секунд, удаляя версию обратного сканирования на расстояние. Ницца.
Кэш умножения
Тем не менее, я понял, что, хотя я выполнял два умножения за итерацию, была возможность использовать кеш для хранения этих умножений, чтобы я мог избежать необходимости выполнять их во время обратной итерации. Мне было приятно видеть, когда я проснулся, что у кого-то была такая же идея!Дело в том, что в числах, которые мы сканируем, не более 10 цифр и всего 10 возможных цифр, поэтому только 100 значений для значения цифры составляют совокупную сумму. Мы можем прекомпилировать их, а затем использовать их в коде обратного сканирования. Это должно превзойти версию форвардного сканирования, потому что теперь мы полностью избавились от умножений. (Обратите внимание, что мы не можем сделать это с помощью прямого сканирования, потому что умножение происходит от аккумулятора, который может принимать любое значение до 10 ^ 9. Это только в обратном случае, что оба операнда ограничены несколькими возможностями.)
private long sumBinaryCached() throws IOException {
int mulCache[][] = new int[10][10];
int coeff = 1;
for (int i = 0; i < 10; i++) {
for (int j = 0; j < 10; j++)
mulCache[i][j] = coeff * j;
coeff *= 10;
}
RandomAccessFile raf = new RandomAccessFile(file, "r");
int lastRead = (int) raf.length();
byte buf[] = new byte[16 * 1024];
int mul = 0;
long total = 0;
while (lastRead > 0) {
int len = Math.min(buf.length, lastRead);
raf.seek(lastRead - len);
raf.readFully(buf, 0, len);
lastRead -= len;
for (int i = len - 1; i >= 0; i--) {
if ((buf[i] >= 48) && (buf[i] <= 57))
total += mulCache[mul++][buf[i] - 48];
else
mul = 0;
}
}
raf.close();
return total;
}
Это выполняется в 26,1 секунды. Разочаровывать, если не сказать больше. Чтение назад менее эффективно с точки зрения ввода-вывода, но мы видели, что I/O не является главной головной болью здесь. Я ожидал, что это будет иметь большое положительное значение. Возможно, поиск массивов столь же дорог, как и умножения, которые мы заменили. (Я попытался сделать массив 16x16 и использовать бит-строки для индексации, но это не помогло.)
Похоже, что прямое сканирование находится там, где оно находится.
Использование MappedByteBuffer
Следующее, что нужно добавить, - это MappedByteBuffer
, чтобы убедиться, что это более эффективно, чем использование raw RandomAccessFile
. Это не нуждается в большом изменении кода.
private long sumBinaryForwardMap() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
byte buf[] = new byte[16 * 1024];
final FileChannel ch = raf.getChannel();
int fileLength = (int) ch.size();
final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, 0,
fileLength);
int acc = 0;
long total = 0;
while (mb.hasRemaining()) {
int len = Math.min(mb.remaining(), buf.length);
mb.get(buf, 0, len);
for (int i = 0; i < len; i++)
if ((buf[i] >= 48) && (buf[i] <= 57))
acc = acc * 10 + buf[i] - 48;
else {
total += acc;
acc = 0;
}
}
ch.close();
raf.close();
return total;
}
Кажется, это немного улучшило ситуацию: теперь мы находимся в 19.0 секунд. Мы взяли еще одну секунду с нашего личного успеха!
Как насчет многопоточности?
Один из предложенных ответов включает использование нескольких ядер. Мне немного стыдно, что это не пришло ко мне!
Ответ пришел для какой-то палки из-за предположения, что это проблема с привязкой к I/O. Это кажется немного суровым, в свете результатов о I/O! Конечно, стоит попробовать, в любом случае.
Мы сделаем это, используя fork/join. Здесь класс, представляющий результат вычисления на части файла, имея в виду, что слева может быть частичный результат (если мы начали половину пути через число), а частичный результат вправо (если буфер завершен на полпути через номер). У класса также есть метод, позволяющий нам склеить два таких результата вместе, в комбинированный результат для двух смежных подзадач.
private class SumTaskResult {
long subtotal;
int leftPartial;
int leftMulCount;
int rightPartial;
public void append(SumTaskResult rightward) {
subtotal += rightward.subtotal + rightPartial
* rightward.leftMulCount + rightward.leftPartial;
rightPartial = rightward.rightPartial;
}
}
Теперь бит ключа: RecursiveTask
, который вычисляет результат. Для небольших проблем (менее 64 символов) он вызывает computeDirectly()
для вычисления результата в одном потоке; для больших задач он разбивается на две части, решает две подзадачи в отдельных потоках и затем объединяет результаты.
private class SumForkTask extends RecursiveTask<SumTaskResult> {
private byte buf[];
// startPos inclusive, endPos exclusive
private int startPos;
private int endPos;
public SumForkTask(byte buf[], int startPos, int endPos) {
this.buf = buf;
this.startPos = startPos;
this.endPos = endPos;
}
private SumTaskResult computeDirectly() {
SumTaskResult result = new SumTaskResult();
int pos = startPos;
result.leftMulCount = 1;
while ((buf[pos] >= 48) && (buf[pos] <= 57)) {
result.leftPartial = result.leftPartial * 10 + buf[pos] - 48;
result.leftMulCount *= 10;
pos++;
}
int acc = 0;
for (int i = pos; i < endPos; i++)
if ((buf[i] >= 48) && (buf[i] <= 57))
acc = acc * 10 + buf[i] - 48;
else {
result.subtotal += acc;
acc = 0;
}
result.rightPartial = acc;
return result;
}
@Override
protected SumTaskResult compute() {
if (endPos - startPos < 64)
return computeDirectly();
int mid = (endPos + startPos) / 2;
SumForkTask left = new SumForkTask(buf, startPos, mid);
left.fork();
SumForkTask right = new SumForkTask(buf, mid, endPos);
SumTaskResult rRes = right.compute();
SumTaskResult lRes = left.join();
lRes.append(rRes);
return lRes;
}
}
Обратите внимание, что это работает на byte[]
, а не на целом MappedByteBuffer
. Причина этого в том, что мы хотим сохранить доступ к диску последовательным. Мы возьмем довольно большие куски, вилку/соединение, а затем перейдем к следующему фрагменту.
Здесь используется метод, который делает это. Обратите внимание, что мы увеличили размер буфера до 1 Мбайт (субоптимальный ранее, но более разумный здесь, похоже).
private long sumBinaryForwardMapForked() throws IOException {
RandomAccessFile raf = new RandomAccessFile(file, "r");
ForkJoinPool pool = new ForkJoinPool();
byte buf[] = new byte[1 * 1024 * 1024];
final FileChannel ch = raf.getChannel();
int fileLength = (int) ch.size();
final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, 0,
fileLength);
SumTaskResult result = new SumTaskResult();
while (mb.hasRemaining()) {
int len = Math.min(mb.remaining(), buf.length);
mb.get(buf, 0, len);
SumForkTask task = new SumForkTask(buf, 0, len);
result.append(pool.invoke(task));
}
ch.close();
raf.close();
pool.shutdown();
return result.subtotal;
}
Теперь вот душераздирающее разочарование: этот красиво многопоточный код теперь занимает 32,2 секунды. Почему так медленно? Я довольно долго отлаживал это, полагая, что сделал что-то ужасное.
Оказывается, требуется только одна небольшая настройка. Я думал, что порог 64 между небольшой проблемой и большой проблемой был разумным; оказывается, это было совершенно нелепо.
Подумайте об этом так. Суб-проблемы имеют одинаковый размер, поэтому они должны выполняться почти в одно и то же время. Таким образом, на самом деле нет смысла разбивать на куски больше, чем есть доступные процессоры. На машине, которую я использую, только с двумя ядрами, спустившись до порога 64, смешно: это просто добавляет дополнительные накладные расходы.
Теперь вы не хотите ограничивать вещи, чтобы использовать только два ядра, даже если их больше. Возможно, правильная вещь - выяснить количество процессоров во время выполнения и разделить на несколько частей.
В любом случае, если я изменю порог на 512 КБ (половина размера буфера), он теперь завершается в 13,3 секунды. Переход на 128 КБ или 64 КБ позволит использовать больше ядер (до 8 или 16 соответственно) и не оказывает существенного влияния на время выполнения.
Значит, многопоточность делает.
Это было довольно длинное путешествие, но мы начали с чего-то, что заняло 92,9 секунды, и теперь мы доходим до 13,3 секунды... это в семь раз быстрее исходного кода. И это не улучшило асимптотическую (большую-О) временную сложность, которая была линейной (оптимальной) с самого начала... все это было связано с улучшением постоянного коэффициента.
Хорошая работа дня.
Полагаю, я должен, вероятно, попробовать использовать следующий GPU...
Postscript: генерация файла случайных чисел
Я создал случайные числа со следующим кодом, который я запускал и перенаправлял в файл. Очевидно, я не могу гарантировать, что у вас будут точно такие же случайные числа, которые у меня были:)
public static void genRandoms() {
Random r = new Random();
for (int i = 0; i < 100000000; i++)
System.out.println(r.nextInt(1000000000));
}
public static void genRandoms() {
Random r = new Random();
for (int i = 0; i < 100000000; i++)
System.out.println(r.nextInt(1000000000));
}