четверг, 26 октября 2023 г.

Потоки надо обеспечивать непрерывными данными для максимальной скорости

 Сделал небольшой тест. Мы берем массив точек и поворачиваем их вокруг какой-то оси в трехмерном пространстве путем перемножения матрицы вращения на точку. Умножение матрицы вращения на 90 град вокруг z-оси на точку (1, 0, 0) в 3d выглядит так: 



И матрица вращения вокруг произвольной оси (углы по осям X,Y, Z):
подробнее здесь

Самое интересное начивается, когда мы, ускоряя процесс вычислений, распаллеливаем их между потоками. У нас в стандартной библиотеке thread есть функция std::thread::hardware_concurrency(), которая возвращает количество логических процессоров (с учетом HT/SMT). Между ними мы и делим данные, чтобы каждый поток обрабатывал свой кусочек. Это можно сделать двумя способами. Первый способ: каждый поток имеет айди от 0 до N-1, где N - это количество логических процессоров (то, что возвращает ф-я std::thread::hardware_concurrency), тогда мы можем давать каждому потоку весь массив и он будет обрабатывать в нем элемент, соотв. своему айди. То есть в каждой порции данных поток выбирает свой элемент и его обрабатывает. Можно улучшить этот способ путем обработки каждым потоком не одного элемента, а сразу многих. Выглядит это в коде вот так:

void applyToPoints(std::vector<Point3f>& points, const int elemCnt, PointFunc func, 
                           const unsigned int thrCnt = std::thread::hardware_concurrency())
{
    std::vector<std::thread> workers;
    for (int thrId = 0; thrId < thrCnt; ++thrId)
    {
        workers.push_back(std::thread([thrId, thrCnt, elemCnt, &points, &func]()
        {
            for (int i = thrId * elemCnt; i < vectSize; i += thrCnt * elemCnt)
            {
	        int blockEndIdx = std::min(i + elemCnt, (int)vectSize);
	        for (int j = i; j < blockEndIdx; ++j)
	        {
	            func(points[j]);
		}
	    }
        }));
    }
 
    for (std::thread& t : workers)
    {
        t.join();
    }
    workers.clear();
}

Второй способ: мы делим весь массив на равные части и "скармливаем" каждую часть своему потоку и он уже непрерывно обрабатывает все элементы:

void applyToPoints2ndWay(std::vector<Point3f>& points, PointFunc func, 
                          const unsigned int thrCnt = std::thread::hardware_concurrency())
{
    auto start = std::chrono::steady_clock::now();
    std::vector<std::thread> workers;
    size_t rod = points.size() % thrCnt;
    auto thrItemCnt = (points.size() - rod) / thrCnt;
    for (int thrId = 0; thrId < thrCnt; ++thrId)
    {
	workers.push_back(std::thread([thrId, thrCnt, thrItemCnt, &points, &func]()
  	    {
		auto begIdx = thrId * thrItemCnt;
		bool isLastThread = (thrId == thrCnt - 1);
		auto endIdx = isLastThread ? points.size() : begIdx + thrItemCnt;
		for (auto i = begIdx; i < endIdx; ++i)
		{
		    func(points[i]);
		}
	    }));
    }
 
    for (std::thread& t : workers)
    {
	t.join();
    }
    workers.clear();
}
Теперь посмотрим на результаты:

-- create array..
-- create array done!
-- init array:
threads: 16, block size:  225000000 bytes, time: 2.15009
threads:  8, block size:  450000000 bytes, time: 2.77854
-- init array done!
-- 1st way: many small blocks:
threads:  1, block size:         12 bytes, time: 2.31308
threads: 16, block size:         12 bytes, time: 1.00347
threads: 16, block size:         96 bytes, time: 0.575906
threads: 16, block size:       1536 bytes, time: 0.39477
threads: 16, block size:      12288 bytes, time: 0.348051
threads: 16, block size:     122880 bytes, time: 0.324958
-- 2st way: the biggest blocks:
threads: 16, block size:  225000000 bytes, time: 0.338135
threads:  8, block size:  450000000 bytes, time: 0.340936
threads:  4, block size:  900000000 bytes, time: 0.593947
threads:  2, block size: 1800000000 bytes, time: 1.14009
Обращают на себя внимание несколько моментов. 
Во-первых, если каждый поток обрабатывает за одну итерацию только один элемент массива (12 байт), то 16 потоков работают с такой же скоростью, как 2 потока, каждый из которых обрабатывает свой массив полностью. Ну или 16 потоков, каждый из которых за одну итерацию обрабатывает по 8 элементов (96 байт) равны по производительности 4 потокам, каждый из которых обрабатывает свою часть массива. И так далее. Видно, что скорости работы обоих алкгоритмов почти сравниваются где-то между 128 элементами (1.5 килобайта) и 1024 элементами (12 килобайт) за одну итерацию. То есть ядрам процессора лучше скармливать достаточно крупные непрерывные блоки данных размером больше килобайта, чтобы они минимально ждали, пока будет доставлен следующий блок из памяти. 
Во-вторых, как по-разному работает технология SMT в разных задачах. Для основной задачи - умножения матрицы на точку - разницы между 8 и 16 потоками почти нет. А вот для вспомогательной задачи - инициализация массива - SMT работает в 1.3 раза быстрее (2.15 сек при 16 потоках и 2.78 при 8 потоках). Не очень понимаю, почему именно так, но вот.

Исходный код здесь. Тестовая платформа: Ryzen7 3700X, 16GB RAM.

Комментариев нет:

Отправить комментарий