Что может заставить программу работать медленнее при использовании большего количества потоков? - PullRequest
2 голосов
/ 05 марта 2009

Этот вопрос касается той же программы, которую я ранее задавал о . Напомним, у меня есть программа со структурой цикла, подобной этой:

for (int i1 = 0; i1 < N; i1++)
  for (int i2 = 0; i2 < N; i2++)
    for (int i3 = 0; i3 < N; i3++)
      for (int i4 = 0; i4 < N; i4++)
        histogram[bin_index(i1, i2, i3, i4)] += 1;

bin_index является полностью детерминированной функцией своих аргументов, которая для целей этого вопроса не использует и не изменяет какое-либо общее состояние - другими словами, оно явно возвращается.

Я впервые написал эту программу, чтобы использовать один поток. Затем я преобразовал его в несколько потоков, так что поток n выполняет все итерации внешнего цикла, где i1 % nthreads == n. Таким образом, функция, которая выполняется в каждом потоке, выглядит как

for (int i1 = n; i1 < N; i1 += nthreads)
  for (int i2 = 0; i2 < N; i2++)
    for (int i3 = 0; i3 < N; i3++)
      for (int i4 = 0; i4 < N; i4++)
        thread_local_histogram[bin_index(i1, i2, i3, i4)] += 1;

и все thread_local_histogram добавляются в основной поток в конце.

Вот странная вещь: когда я запускаю программу с одним потоком для какого-то определенного размера вычисления, это занимает около 6 секунд. Когда я запускаю его с 2 или 3 потоками, выполняя точно такой же расчет, это занимает около 9 секунд. Это почему? Я ожидал бы, что использование 2 потоков будет быстрее, чем 1 потока, так как у меня двухъядерный процессор. Программа не использует мьютексы или другие примитивы синхронизации, поэтому два потока должны работать параллельно.

Для справки: типичный вывод из time (это в Linux) для одного потока:

real    0m5.968s
user    0m5.856s
sys     0m0.064s

и две темы:

real    0m9.128s
user    0m10.129s
sys     0m6.576s

Код: http://static.ellipsix.net/ext-tmp/distintegral.ccs

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

Ответы [ 7 ]

11 голосов
/ 05 марта 2009

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


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

2) Предотвращение всплесков памяти
Память читается быстрее, если читать последовательно в пакетах, так же, как когда файлы читаются с HD. Обращение к определенной точке памяти на самом деле очень медленное (как и «время поиска» на HD), даже если у вашего ПК лучшая память на рынке. Однако, как только эта точка была решена, последовательное чтение выполняется быстро. Первая адресация идет путем отправки индекса строки и индекса столбца и всегда с промежутками ожидания, прежде чем будут доступны первые данные. Как только эти данные есть, процессор начинает лопаться. Пока данные находятся в пути, они уже отправляют запрос на следующий пакет. Пока он поддерживает пакет (всегда отправляя запросы «Следующая строка, пожалуйста»), ОЗУ будет продолжать откачивать данные настолько быстро, насколько это возможно (и это на самом деле довольно быстро!). Пакетная обработка работает только в том случае, если данные читаются последовательно и только в том случае, если адреса памяти растут вверх (AFAIK, вы не можете выполнить пакетную передачу с высоких на низкие адреса). Если теперь два потока работают одновременно и оба сохраняют память для чтения / записи, однако оба имеют совершенно разные адреса памяти, каждый раз, когда потоку 2 требуется чтение / запись данных, он должен прервать возможный пакет потока 1 и наоборот , Эта проблема усугубляется, если у вас есть еще больше потоков, и эта проблема также проблема в системе, которая имеет только один одноядерный процессор.

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

5 голосов
/ 05 марта 2009

Все, что я сказал до сих пор в своем другом ответе, остается верным в целом, поскольку ваш вопрос был о том, что «может» ... однако теперь, когда я увидел ваш реальный код, моя первая ставка была бы на то, что вы используете Функция random () замедляет все. Почему?

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

Кроме того, чтобы сделать ситуацию еще хуже, у glibc есть поточно-ориентированная версия random () - я только что проверил это, посмотрев на источник. Хотя на практике это кажется хорошей идеей, это означает, что каждый вызов random () будет вызывать блокировку мьютекса, доступ к памяти и разблокировку мьютекса. Таким образом, два потока, вызывающие случайное совпадение в один и тот же момент, приведут к блокировке одного потока на пару циклов ЦП. Однако это зависит от реализации, так как AFAIK не требует, чтобы random () был потокобезопасным. Большинство стандартных функций lib не обязательно должны быть поточно-ориентированными, поскольку стандарт C даже не знает о концепции потоков. Когда они не вызывают его в один и тот же момент, мьютекс не будет влиять на скорость (так как даже однопоточное приложение должно блокировать / разблокировать мьютекс), но затем отравление кеша снова будет применяться.

Вы можете предварительно построить массив со случайными числами для каждого потока, содержащий столько случайных чисел, сколько нужно каждому потоку. Создайте его в главном потоке перед порождением потоков и добавьте ссылку на него в указатель структуры, который вы передаете каждому потоку. Тогда получите случайные числа оттуда.

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

Если у вас работает решение только для Linux, вы можете использовать random_r . Позволяет передавать состояние при каждом вызове. Просто используйте уникальный объект состояния для каждого потока. Однако эта функция является расширением glibc, и, скорее всего, она не поддерживается другими платформами (ни частью стандартов C, ни стандартов POSIX AFAIK - эта функция не существует, например, в Mac OS X, она может не существовать в Solaris или FreeBSD).

Создание собственного генератора случайных чисел на самом деле не так сложно. Если вам нужны реальные случайные числа, вы не должны использовать random (). Функция Random создает только псевдослучайные числа (числа, которые выглядят случайными, но являются предсказуемыми, если вы знаете внутреннее состояние генератора). Вот код для одного, который производит хорошие случайные числа uint32:

static uint32_t getRandom(uint32_t * m_z, uint32_t * m_w)
{
    *m_z = 36969 * (*m_z & 65535) + (*m_z >> 16);
    *m_w = 18000 * (*m_w & 65535) + (*m_w >> 16);
    return (*m_z << 16) + *m_w;
}

Важно как-то правильно "затравить" m_z и m_w, иначе результаты не будут случайными вообще. Само начальное значение уже должно быть случайным, но здесь вы можете использовать системный генератор случайных чисел.

uint32_t m_z = random();
uint32_t m_w = random();
uint32_t nextRandom;

for (...) {
    nextRandom = getRandom(&m_z, &m_w);
    // ...
}

Таким образом, каждый поток должен вызывать random () дважды, а затем использовать собственный генератор. Кстати, если вам нужны двойные рандомы (от 0 до 1), вышеприведенную функцию можно легко обернуть для этого:

static double getRandomDouble(uint32_t * m_z, uint32_t * m_w)
{
    // The magic number below is 1/(2^32 + 2).
    // The result is strictly between 0 and 1.
    return (getRandom(m_z, m_w) + 1) * 2.328306435454494e-10;
}

Попробуйте внести это изменение в свой код и дайте мне знать, каковы результаты теста: -)

2 голосов
/ 05 марта 2009

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

1 голос
/ 05 марта 2009

с макушки головы:

  • Переключатели контекста
  • Ресурсный конфликт
  • Конфликт ЦП (если они не разделяются на несколько ЦП).
  • Кэш побеждает
1 голос
/ 05 марта 2009

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

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

1 голос
/ 05 марта 2009

Одна из возможностей заключается в том, что время, затрачиваемое на создание потоков, превышает экономию, полученную благодаря использованию потоков. Я думаю, что N не очень велико, если для операции O (n ^ 4) прошло всего 6 секунд.

Также нет гарантии, что несколько потоков будут работать на разных ядрах или процессорах. Я не уверен, что по умолчанию сходство потоков с Linux - может быть в том, что оба потока работают на одном ядре, что сводит на нет преимущества кода, интенсивно использующего процессор, такого как этот. *

В этой статье подробно описывается сходство потоков по умолчанию и способы изменения кода для обеспечения работы потоков на определенных ядрах.

0 голосов
/ 05 марта 2009

David

Вы уверены, что используете ядро, которое поддерживает несколько процессоров? Если в вашей системе используется только один процессор, создание дополнительных потоков, интенсивно использующих процессор, замедлит вашу программу.

И вы уверены, что поддержка потоков в вашей системе на самом деле использует несколько процессоров? Например, показывает ли top, что оба ядра вашего процессора использовались при запуске вашей программы?

...