Разделение программы на 4 потока происходит медленнее, чем один поток - PullRequest
6 голосов
/ 24 января 2012

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

При чтении других похожих вопросов, особенно об OpenMP, было высказано предположение, что gcc оптимизирует последовательный код лучше.Однако выполнение скомпилированного кода ниже с export OMP_NUM_THREADS=1 в два раза быстрее, чем с export OMP_NUM_THREADS=4.Т.е. это одинаковый скомпилированный код на обоих запусках.

Запуск программы с time:

> export OMP_NUM_THREADS=1; time ./raytracer
real    0m34.344s
user    0m34.310s
sys     0m0.008s


> export OMP_NUM_THREADS=4; time ./raytracer
real    0m53.189s
user    0m20.677s
sys     0m0.096s

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

Код, который я распараллелил с использованием OpenMP

void Raytracer::render( Camera& cam ) {

    // let the camera know to use this raytracer for probing the scene
    cam.setSamplingFunc(getSamplingFunction());

    int i, j;

    #pragma omp parallel private(i, j)
    {

        // Construct a ray for each pixel.
        #pragma omp for schedule(dynamic, 4)
        for (i = 0; i < cam.height(); ++i) {
            for (j = 0; j < cam.width(); ++j) {
                cam.computePixel(i, j);
            }
        }
    }
}

Читая этот вопрос Я думал, что нашел свой ответ.В нем рассказывается о реализации gclib rand (), синхронизирующей вызовы с самим собой, чтобы сохранить состояние для генерации случайных чисел между потоками.Я часто использую rand () для выборки Монте-Карло, поэтому я подумал, что это проблема.Я избавился от вызовов rand, заменив их одним значением, но использование нескольких потоков все еще медленнее. РЕДАКТИРОВАТЬ: упс оказывается, я не проверял это правильно, это были случайные значения!

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

В моем raytracer у меня по существу есть дерево сцены со всеми объектами в нем.Это дерево часто пересекается в течение computePixel, когда объекты проверяются на пересечение, однако запись в это дерево или любые объекты не производится.computePixel, по сути, читает сцену несколько раз, вызывая методы для объектов (все из которых являются const-методами), и в самом конце записывает одно значение в свой собственный массив пикселей.Это единственная часть, о которой мне известно, когда более чем один поток попытается записать в одну переменную-член.Синхронизация нигде не существует, поскольку два потока не могут записывать в одну и ту же ячейку в массиве пикселей.

Кто-нибудь может предложить места, где могут быть какие-то разногласия?Что попробовать?

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

РЕДАКТИРОВАТЬ: Извините, глупо не предоставлять дополнительную информацию о моей системе.

  • Компилятор gcc 4.6 (с оптимизацией -O2)
  • Ubuntu Linux 11.10
  • OpenMP 3
  • Четырехъядерный процессор Intel i3-2310M 2,1 ГГц (на моем ноутбуке на данный момент)

Код для вычисления пикселя:

class Camera {

    // constructors destructors
    private:
        // this is the array that is being written to, but not read from.
        Colour* _sensor; // allocated using new at construction.
}

void Camera::computePixel(int i, int j) const {

    Colour col;

    // simple code to construct appropriate ray for the pixel
    Ray3D ray(/* params */);
    col += _sceneSamplingFunc(ray); // calls a const method that traverses scene. 

    _sensor[i*_scrWidth+j] += col;
}

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

Ответы [ 4 ]

4 голосов
/ 02 февраля 2012

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

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

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

Я проверил код, и он работает - нет блокировки и заметного замедления в результате обращений к threadrand. Не стесняйтесь указывать на любые вопиющие ошибки.

threadrand.h

#ifndef _THREAD_RAND_H_
#define _THREAD_RAND_H_

// max number of thread states to store
const int maxThreadNum = 100;

void init_threadrand();

// requires openmp, for thread number
int threadrand();

#endif // _THREAD_RAND_H_

threadrand.cpp

#include "threadrand.h"
#include <cstdlib>
#include <boost/scoped_ptr.hpp>
#include <omp.h>

// can be replaced with array of ordinary pointers, but need to
// explicitly delete previous pointer allocations, and do null checks.
//
// Importantly, the double indirection tries to avoid putting all the
// thread states on the same cache line, which would cause cache invalidations
// to occur on other cores every time rand_r would modify the state.
// (i.e. false sharing)
// A better implementation would be to store each state in a structure
// that is the size of a cache line
static boost::scoped_ptr<unsigned int> randThreadStates[maxThreadNum];

// reinitialize the array of thread state pointers, with random
// seed values.
void init_threadrand() {
    for (int i = 0; i < maxThreadNum; ++i) {
        randThreadStates[i].reset(new unsigned int(std::rand()));
    }
}

// requires openmp, for thread number, to index into array of states.
int threadrand() {
    int i = omp_get_thread_num();
    return rand_r(randThreadStates[i].get());
}

Теперь вы можете инициализировать случайные состояния для потоков из main, используя init_threadrand(), и впоследствии получить случайное число, используя threadrand() при использовании нескольких потоков в OpenMP.

2 голосов
/ 24 января 2012

Ответ заключается в том, что, не зная, на какой машине вы работаете, и не видя код вашей функции computePixel, все зависит от этого.

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

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

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

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

Являются ли данные записанными в конце, данными, которые должны быть в состоянии сделать другие потоки computePixel?

1 голос
/ 24 января 2012

Я только что посмотрел, и Intel i3-2310M на самом деле не имеет 4 ядер, у него 2 ядра и гиперпоточность. Попробуйте запустить свой код только с 2-мя потоками и посмотрите, как это помогает. Я нахожу, что гиперпоточность совершенно бесполезна, когда у вас много вычислений, и на своем ноутбуке я отключил ее и получил намного лучшее время компиляции моих проектов.

На самом деле, просто зайдите в BIOS и выключите HT - это бесполезно для машин разработки / вычисления.

1 голос
/ 24 января 2012

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

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

Чтобы исправить это, необходимо убедиться, что каждый поток действительно работает в независимой области.Прямо сейчас кажется, что вы делите на строки (я не уверен, так как я не знаю OMP).Работает ли это, зависит от того, насколько велики ваши строки - но все же конец каждой строки будет перекрываться с началом следующей (в терминах строк кэша).Возможно, вы захотите попытаться разбить изображение на четыре блока и заставить каждый поток работать в последовательности последовательных строк (например, 1..10 11..20 21..30 31..40).Это значительно сократило бы совместное использование.

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

...