Вопрос
Предположим, что у вас есть большой текстовый файл 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));
}
