Будет ли многопоточность повышать производительность? - PullRequest
15 голосов
/ 10 июля 2009

Я новичок в программировании в целом, поэтому имейте это в виду, когда отвечаете на мой вопрос.

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

Вопрос в том, получу ли я какое-либо увеличение производительности, если я буду использовать многопоточность программы, или я попаду в узкое место доступа к ОЗУ? Когда я говорю многопоточность, я имею в виду многопоточность только для 2 или 4 ядер, не более.

Если это поможет, моя текущая конфигурация компьютера: 2,4 ГГц Core2 Quad, 1033 FSB, 4 ГБ оперативной памяти на 667 МГц.

Заранее спасибо,

-Faken

Редактировать:

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

Прежде всего, немного обо мне, чтобы вы понимали, откуда я. Я аспирант по машиностроению, который каким-то образом сумел выбрать тему, которая не имела никакого отношения к машиностроению. Я прошел 1 курс начального Java (принудительно) примерно 5 лет назад и никогда не занимался программированием, пока около месяца назад я не начал свою диссертацию всерьез. Я также прошел (опять-таки вынужденный, до сих пор не знаю, почему) курс по электронике и компьютерной инженерии, мы изучили микроконтроллеры (8-разрядные), их внутреннюю работу и некоторое кодирование ASM для них. Кроме этого, я почти ничего не знаю о программировании.

Вот код:

int dim = 1000;
int steps = 7 //ranges from 1 to  255

for (int stage = 1; stage < steps; stage++)
for (int j = 0; j < dim; j++)
    for (int i = 0; i < dim; i++)
    {
        sum = 0;
        for (int k = 0; k < dim; k++)
            if (partMap[(((i * dim) + k) * dim) + j] >= stage)
                sum++;

        projection[(j*dim) + i] = sum;
    } 

Этот раздел кода работает только по оси Z. Основные данные, благодаря тому, как они были построены, имеют странную систему адресации, но вам не нужно об этом беспокоиться. Существует также другой код для выполнения проекций других сторон куба, но они делают совершенно разные вещи.

Ответы [ 19 ]

31 голосов
/ 10 июля 2009

Многопоточность по нескольким ядрам может сократить время, необходимое для суммирования по осям, но требуется особая осторожность. На самом деле вы можете получить большее повышение производительности от некоторых изменений, которые вы можете внести в ваш однопоточный код:

  1. Вам нужно только столько потоков, чтобы соответствовать количеству доступных вам ядер. Это ресурсоемкая операция, и потоки вряд ли будут ожидать ввода-вывода.

  2. Приведенное выше предположение может не выполняться, если весь массив не помещается в ОЗУ. Если части массива выгружаются и выгружаются, некоторые потоки будут ожидать завершения операций подкачки. В этом случае программе может быть полезно иметь больше потоков, чем ядер. Слишком много, однако, и производительность снизится из-за стоимости переключения контекста. Возможно, вам придется поэкспериментировать с количеством потоков. Общее правило - минимизировать количество переключений контекста между готовыми потоками.

  3. Если весь массив не помещается в ОЗУ, вы хотите свести к минимуму подкачку! Порядок, в котором каждый поток обращается к памяти, имеет значение, как и шаблон доступа к памяти всех запущенных потоков. Насколько это возможно, вы хотели бы закончить с одной частью массива, прежде чем переходить к следующей, чтобы никогда не возвращаться в покрытую область.

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

  5. Каждое ядро ​​также получит выгоду от доступа к большему количеству данных из своего кэша, в отличие от извлечения из ОЗУ. Это означало бы упорядочение циклов таким образом, чтобы внутренние циклы обращались к близлежащим словам, а не пропускали строки.

  6. Наконец, в зависимости от типов данных в массиве, инструкции SIMD процессоров Intel / AMD (SSE, в их различных поколениях) могут помочь ускорить производительность одного ядра, суммируя несколько ячеек одновременно. VC ++ имеет некоторую встроенную поддержку .

  7. Если вам нужно расставить приоритеты в своей работе, вы можете сначала минимизировать разбиение на страницы диска, а затем сосредоточиться на оптимизации доступа к памяти для использования кэшей ЦП и только затем заниматься многопоточностью.

11 голосов
/ 11 июля 2009

Есть только один способ оптимизировать код: выяснить, что вы делаете медленно, и делать меньше. Особый случай «делать меньше» - это делать что-то еще, а это быстрее.

Итак, прежде всего, вот что я делаю, основываясь на опубликованном вами коде:

#include <fstream>
#include <sstream>
using std::ios_base;

template<typename Iterator, typename Value>
void iota(Iterator start, Iterator end, Value val) {
    while (start != end) {
        *(start++) = val++;
    }
}

int main() {

    const int dim = 1000;
    const int cubesize = dim*dim*dim;
    const int squaresize = dim*dim;
    const int steps = 7; //ranges from 1 to  255
    typedef unsigned char uchar;

    uchar *partMap = new uchar[cubesize];
    // dummy data. I timed this separately and it takes about
    // a second, so I won't worry about its effect on overall timings.
    iota(partMap, partMap + cubesize, uchar(7));
    uchar *projection = new uchar[squaresize];

    for (int stage = 1; stage < steps; stage++) {
        for (int j = 0; j < dim; j++) {
                for (int i = 0; i < dim; i++)
                {
                        int sum = 0;
                        for (int k = 0; k < dim; k++)
                            if (partMap[(((i * dim) + k) * dim) + j] >= stage)
                                sum++;

                        projection[(j*dim) + i] = sum;
                }
        }

        std::stringstream filename;
        filename << "results" << stage << ".bin";
        std::ofstream file(filename.str().c_str(), 
            ios_base::out | ios_base::binary | ios_base::trunc);
        file.write((char *)projection, squaresize);
    }

    delete[] projection;
    delete[] partMap;
}

(Правка: только что заметил, что «проекция» должна быть массивом int, а не uchar. Мой плохой. Это будет иметь значение для некоторых моментов времени, но, надеюсь, не слишком большое из них.)

Затем я скопировал result*.bin в gold*.bin, чтобы я мог проверить свои будущие изменения следующим образом:

$ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
6; do diff -q results$n.bin gold$n.bin; done
g++  -O3 -pedantic -Wall   big.cpp   -o big

real    1m41.978s
user    1m39.450s
sys     0m0.451s

ОК, сейчас 100 секунд.

Итак, предположив, что он медленно обрабатывает массив данных из миллиарда элементов, давайте попробуем пройти только один раз, а не один раз за этап:

    uchar *projections[steps];
    for (int stage = 1; stage < steps; stage++) {
         projections[stage] = new uchar[squaresize];
    }

    for (int j = 0; j < dim; j++) {
            for (int i = 0; i < dim; i++)
            {
                    int counts[256] = {0};
                    for (int k = 0; k < dim; k++)
                            counts[partMap[(((i * dim) + k) * dim) + j]]++;

                    int sum = 0;
                    for (int idx = 255; idx >= steps; --idx) {
                        sum += counts[idx];
                    }
                    for (int stage = steps-1; stage > 0; --stage) {
                        sum += counts[stage];
                        projections[stage][(j*dim) + i] = sum;
                    }
            }
    }

    for (int stage = 1; stage < steps; stage++) {
        std::stringstream filename;
        filename << "results" << stage << ".bin";
        std::ofstream file(filename.str().c_str(),
            ios_base::out | ios_base::binary | ios_base::trunc);
        file.write((char *)projections[stage], squaresize);
    }

    for (int stage = 1; stage < steps; stage++) delete[] projections[stage];
    delete[] partMap;

Это немного быстрее:

$ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
6; do diff -q results$n.bin gold$n.bin; done
g++  -O3 -pedantic -Wall   big.cpp   -o big

real    1m15.176s
user    1m13.772s
sys     0m0.841s

Теперь steps в этом примере довольно мало, поэтому мы выполняем много ненужной работы с массивом "count". Даже без профилирования, я предполагаю, что подсчет до 256 дважды (один раз для очистки массива и один раз для его суммирования) является довольно значительным по сравнению со счетом до 1000 (для бега по нашему столбцу). Итак, давайте изменим это:

    for (int j = 0; j < dim; j++) {
            for (int i = 0; i < dim; i++)
            {
                    // steps+1, not steps. I got this wrong the first time,
                    // which at least proved that my diffs work as a check
                    // of the answer...
                    int counts[steps+1] = {0};
                    for (int k = 0; k < dim; k++) {
                        uchar val = partMap[(((i * dim) + k) * dim) + j];
                        if (val >= steps) 
                            counts[steps]++;
                        else counts[val]++;
                    }

                    int sum = counts[steps];
                    for (int stage = steps-1; stage > 0; --stage) {
                        sum += counts[stage];
                        projections[stage][(j*dim) + i] = sum;
                    }
            }
    }

Теперь мы используем только столько ведер, сколько нам нужно.

$ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
6; do diff -q results$n.bin gold$n.bin; done
g++  -O3 -pedantic -Wall   big.cpp   -o big

real    0m27.643s
user    0m26.551s
sys     0m0.483s

Hurray. Код почти в 4 раза быстрее первой версии и дает те же результаты. Все, что я сделал, это изменил порядок выполнения математики: мы еще даже не рассматривали многопоточность или предварительную выборку. И я не пытался выполнить техническую оптимизацию циклов, просто оставил ее компилятору. Так что это можно считать достойным началом.

Тем не менее, он по-прежнему занимает на порядок больше, чем 1 с, с которыми работает йота. Так что, вероятно, еще предстоит найти большой выигрыш. Одно из основных отличий заключается в том, что йота перебирает массив 1d в последовательном порядке, а не прыгает повсюду. Как я уже говорил в моем первом ответе, вы должны стремиться всегда использовать последовательный порядок на кубе.

Итак, давайте сделаем однострочное изменение, переключив циклы i и j:

            for (int i = 0; i < dim; i++)
    for (int j = 0; j < dim; j++) {

Это все еще не последовательный порядок, но это означает, что мы фокусируемся на одном кусочке нашего куба по миллиону за раз. Современный процессор имеет по крайней мере 4 МБ кэш-памяти, поэтому, если повезет, мы ударим по основной памяти только для каждой части куба один раз во всей программе. С еще лучшей локализацией мы могли бы также уменьшить трафик в и из кэша L1, но основная память самая медленная.

Какая разница?

$ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
6; do diff -q results$n.bin gold$n.bin; done
g++  -O3 -pedantic -Wall   big.cpp   -o big

real    0m8.221s
user    0m4.507s
sys     0m0.514s

Неплохо. Фактически, одно только это изменение приносит исходный код от 100 до 20 с. Так что это отвечает за коэффициент 5, а все остальное, что я сделал, отвечает за другой фактор 5 (я думаю, что разница между «пользовательским» и «реальным» временем в приведенном выше описании в основном объясняется тем фактом, что мой антивирусный сканер Выполнение, чего раньше не было. «пользователь» - это количество времени, которое программа занимала ЦП, «реальное» - время, затраченное на приостановку (ожидание ввода-вывода или предоставление времени другому процессу).

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

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

Наконец, большая часть моего прироста производительности была получена за счет оптимизации того, что «шагов» мало. С steps=100 я получаю:

$ make big -B CPPFLAGS="-O3 -pedantic -Wall" && time ./big; for n in 1 2 3 4 5
6; do diff -q results$n.bin gold$n.bin; done
g++  -O3 -pedantic -Wall   big.cpp   -o big

real    0m22.262s
user    0m10.108s
sys     0m1.029s

Это не так уж плохо. С шагами = 100 исходный код, вероятно, занимает около 1400 секунд, хотя я не собираюсь запускать его, чтобы доказать это. Но стоит помнить, что я не полностью убрал зависимость времени от «шагов», просто сделал ее сублинейной.

5 голосов
/ 10 июля 2009

Как работает ваш код. Это так?

for each row: add up the values
for each column: add up the values
for each stack: add up the values

Если это так, возможно, вы захотите прочесть о «справочной информации». В зависимости от того, как хранятся ваши данные, вы можете обнаружить, что при работе со стеками для каждого значения должна быть извлечена целая строка кэша, потому что значения не располагаются близко друг к другу в памяти. На самом деле, имея миллиард значений, вы можете извлекать вещи с диска. Последовательный доступ с длинным шагом (расстояние между значениями) - худшее возможное использование для кэша. Попробуйте выполнить профилирование, и если вы видите, что сложение стеков занимает больше времени, чем сложение строк, это почти наверняка почему.

Я думаю, что вы могли бы насыщать шину памяти (*), и в этом случае многопоточность помогла бы, только если core2 quad использует разные шины для разных ядер. Но если вы не насыщаете полосу пропускания шины, вы не сможете получить наилучшую производительность, даже если вы используете многопоточность. Вы будете иметь 4 ядра, тратящих все свое время на остановку кэша, вместо одного.

Если вы ограничены кешем памяти, ваша цель должна состоять в том, чтобы посещать каждую страницу / строку памяти как можно меньше раз. Поэтому я бы попробовал такие вещи, как однократный просмотр данных, добавляя каждое значение к трем различным итоговым значениям. Если это работает быстрее на одном ядре, то мы в бизнесе. Следующим шагом является то, что с кубом 1000x1000x1000 у вас есть 3 миллиона итогов на ходу. Это также не помещается в кеш, поэтому вам нужно беспокоиться о тех же проблемах с пропуском кеша, что и при чтении.

Вы хотите убедиться, что по мере того, как вы пробегаете ряд из 1000 смежных значений в ОЗУ, добавляя к итоговому общему количеству строк, вы также добавляете к смежным итоговым значениям для столбцов и стеков (чего они не делают хранить). Таким образом, «квадрат» итогов столбцов должен храниться соответствующим образом, как и «квадрат» стеков. Таким образом, вы имеете дело с 1000 из ваших миллиардов значений, просто вытягивая около 12 КБ памяти в кэш (4 КБ для 1000 значений, плюс 4 КБ для 1000 итогов столбцов, плюс 4 КБ для 1000 итогов стека). В отличие от этого, вы делаете больше магазинов, чем могли бы, концентрируясь на 1 общем количестве за раз (что, следовательно, может быть в реестре).

Так что я ничего не обещаю, но я думаю, что стоит посмотреть порядок доступа к памяти, независимо от того, многопоточный он или нет. Если вы сможете выполнять больше работы с ЦП при доступе к относительно небольшому объему памяти, то вы ускорите однопоточную версию, но также улучшите свою многопоточность, поскольку ядра совместно используют ограниченный кэш-память шина и основное ОЗУ.

(*) Расчет за пределами конверта: в случайных случайных обзорах за пределами интернета самая высокая расчетная пропускная способность FSB для процессоров Core2, которую я обнаружил до сих пор, - это Extreme со скоростью 12 ГБ / с, с 2 каналами по 4x199 МГц каждый). Размер строки кэша составляет 64 байта, что меньше вашего шага. Таким образом, неправильное суммирование столбца или стека с захватом 64 байта на значение приведет к насыщению шины только в том случае, если она обрабатывает 200 миллионов значений в секунду. Я предполагаю, что это не так быстро (10-15 секунд), иначе вы бы не спросили, как его ускорить.

Так что мое первое предположение, вероятно, было далеко. Если ваш компилятор или процессор не вставили очень умную предварительную выборку, одно ядро ​​не может использовать 2 канала и 4 одновременных передачи за цикл. В связи с этим 4 ядра не могут использовать 2 канала и 4 одновременных передачи. Эффективная пропускная способность шины для ряда запросов может быть намного ниже физического предела, и в этом случае вы надеетесь увидеть хорошие улучшения от многопоточности просто потому, что у вас есть 4 ядра, запрашивающие 4 разные строки кэша, и все они могут быть загружается одновременно, не беспокоясь о FSB или контроллере кэша. Но задержка все еще является убийцей, и поэтому, если вы можете загрузить менее одной строки кэша на каждое суммируемое значение, вы добьетесь большего успеха.

4 голосов
/ 10 июля 2009

Невозможно сказать вообще, потому что вы не указали, насколько быстры ваши ЦП и ОЗУ. Хорошие шансы состоят в том, что это улучшит ситуацию, потому что я не могу себе представить, как даже 4 параллельных потока, суммирующих ОЗУ, будут настолько насыщены, что станут узким местом (а не процессором).

3 голосов
/ 10 июля 2009

Моя интуиция говорит, что вы увидите скромные улучшения. Тем не менее, прогнозирование результатов оптимизации является общеизвестной ошибкой.

Попробуйте и сравните результаты.

2 голосов
/ 10 июля 2009

Многопоточность сделает ваш код быстрее только в том случае, если вычисления можно разбить на куски, с которыми можно работать независимо и одновременно.


EDIT

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

Да, он появляется после прочтения вашего вопроса и с учетом вашего конкретного случая, когда вы выиграете от многопоточности.

Оперативная память очень быстрая, поэтому я думаю, что было бы очень сложно насытить пропускную способность памяти, если у вас много-много потоков.

2 голосов
/ 10 июля 2009

Хотя это, вероятно, будет очень сложно для вас, если вы новичок в программировании, но очень мощный способ ускорить процесс - использовать мощь графического процессора. Мало того, что VRAM намного быстрее обычной оперативной памяти, графический процессор может также выполнять ваш код параллельно на некоторых 128 или более ядрах. Конечно, для такого объема данных вам понадобится довольно большая VRAM.

Если вы решите проверить эту возможность, вам следует поискать nVidia CUDA. Я не проверял это сам, но он предназначен для таких проблем.

2 голосов
/ 10 июля 2009

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

Это поможет вам идентифицировать любые биты вашего кода, которые занимают значительную часть времени вычислений. Подстройка здесь и там после некоторого профилирования иногда может существенно повлиять на производительность.

2 голосов
/ 10 июля 2009

Вопросы, на которые вам нужно ответить для вашего конкретного приложения, хорошо известны.

Во-первых, параллельна ли работа? Закон Амдала даст вам верхнюю границу того, насколько вы можете ускорить процесс с многопоточностью.

Во-вторых, может ли многопоточное решение создать много накладных расходов? Вы говорите, что программа «интенсивно использует ОЗУ, поскольку программа постоянно извлекает информацию из ОЗУ, как для чтения, так и для записи». Таким образом, вам необходимо определить, приведет ли чтение / запись к значительным затратам на координацию . Это не легко Хотя каждый ЦП может получать доступ ко всей оперативной памяти компьютера (как для чтения, так и для записи) в любое время, это может замедлить доступ к памяти - даже без блокировок - поскольку различные ЦП сохраняют свои собственные кеши и должны согласовывать то, что находится в их кешах друг друга (ЦП 1 имеет значение в кеше, ЦП 2 обновляет это значение в ОЗУ, ЦП 2 должен сообщить ЦПУ 1, что его кэш-память недействительна). И если вам нужны блокировки (что является почти гарантией, поскольку вы одновременно «читаете и пишете»), вам нужно как можно больше избегать конфликтов.

В-третьих, вы связаны памятью? «Объем оперативной памяти». это не то же самое, что «память связана». Если вы в настоящее время связаны с процессором, то многопоточность ускорит процесс. Если вы в настоящее время связаны с памятью, тогда многопоточность может даже замедлить работу (если один поток слишком быстр для памяти, что произойдет с несколькими потоками?).

В-четвертых, вы медлительны по какой-то другой причине? Если вы используете new или malloc много памяти в своем алгоритме, вы можете увидеть издержки только от этого. И на многих платформах new и malloc плохо справляются с многопоточностью , поэтому, если вы медлительны прямо сейчас, потому что malloc плохо, многопоточная программа будет работать медленнее, потому что malloc будет хуже.

В целом, однако, не видя ваш код, я ожидал бы, что он будет связан с процессором, и я ожидал бы, что многопоточность ускорит процесс - почти столько, сколько на самом деле предполагает закон Амдаля. Возможно, вы захотите взглянуть на OpenMP или библиотеку Intel Threading Building Blocks, или на какую-то очередь потоков, чтобы это сделать.

2 голосов
/ 10 июля 2009

Я думаю, что даже если многопоточность может повысить производительность, это неправильный подход к оптимизации. Многоядерные процессоры - все в моде, потому что они являются единственным способом для производителей ЦП обеспечить более высокую скорость ЦП с рыночным темпом - не обязательно, потому что они являются удивительным инструментом программирования (еще предстоит много созревания). 1001 *

Всегда смотрите на алгоритм, который вы используете, прежде всего. Вы говорите, что ваша программа очень интенсивно использует ОЗУ - что вы можете сделать, чтобы улучшить попадания в кеш? Есть ли способ сортировки массива так, чтобы вычисления могли применяться линейно? Какой язык программирования вы используете, и будет ли вам полезно оптимизировать язык более низкого уровня? Есть ли способ, которым вы можете использовать динамическое программирование для хранения ваших результатов?

В целом, тратите все свои ресурсы на разработку более эффективного алгоритма, математически и в качестве оптимизации компилятора, а затем беспокойтесь о многоядерности. Конечно, вы уже можете быть на этом этапе, и в этом случае этот комментарий не очень полезен; p

...