четверг, 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.

понедельник, 23 октября 2023 г.

Простенький тест скорости работы shared_ptr vs raw ptr.

 Всегда было интересно, насколько дорого обходятся умные указатели, потому решил написать простенький тест, чтобы проверить насколько все хорошо или плохо. Для сравнение взял три разные структуры: классическиую структуру, структуру с raw-pointers и структуру с shared_ptr.  

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

struct StudentId
{
	char str[20];
};
 
struct StudentMarks
{
	int math;
	int it;
	int lit;
	int pe;
};
 
struct RecommendLetters
{
	int amount;
};
 
// plain struct
struct StudentInfo 
{
	StudentId id;
	StudentMarks marks;
	RecommendLetters letters;
};
 
// raw-pointer struct
struct StudentInfoRP
{
	StudentInfoRP()
	{
		id = new StudentId();
		marks = new StudentMarks();
		letters = new RecommendLetters();
	}
 
	~StudentInfoRP()
	{
		delete id;
		delete marks;
		delete letters;
	}
 
	StudentId* id;
	StudentMarks* marks;
	RecommendLetters* letters;
};
 
// shared pointers struct
struct StudentInfoSP
{
	std::shared_ptr<StudentId> id = std::make_shared<StudentId>();
	std::shared_ptr<StudentMarks> marks = std::make_shared<StudentMarks>();
	std::shared_ptr<RecommendLetters> letters = std::make_shared<RecommendLetters>();
};
Инициализация выглядит так:

std::vector<StudentInfo> studs(amount);
for (int i = 0; i < amount; ++i)
{
        StudentInfo& stud = studs[i];
        sprintf_s(stud.id.str, sizeof(stud.id) / sizeof(char), "%03d-%014d", rand() % 100, i);
        stud.marks.math = rand() % 10;
	stud.marks.it = rand() % 10;
	stud.marks.lit = rand() % 10;
	stud.marks.pe = rand() % 10;
	stud.letters.amount = (rand() % 20 == 5) ? rand() % 4 : 0;
}
Сортировка:

std::sort(studs.begin(), studs.end(), [](const StudentInfo& a, const StudentInfo& b)
{
	if (a.marks.math != b.marks.math)
	{
        	return a.marks.math < b.marks.math;
	}
	else if (a.marks.it != b.marks.it)
	{
		return a.marks.it < b.marks.it;
	}
	else if (a.marks.lit != b.marks.lit)
	{
		return a.marks.lit < b.marks.lit;
	}
	else if (a.marks.pe != b.marks.pe)
	{
		return a.marks.pe < b.marks.pe;
	}
	else if (a.letters.amount != b.letters.amount)
	{
		return a.letters.amount < b.letters.amount;
	}
	else if (strcmp(a.id.str, b.id.str) != 0)
	{
		return strcmp(a.id.str, b.id.str) < 0;
    	}
});
В общем, ничего необычного. Аналогично все и для других двух структур, только там , посколько это указатели, обращение идет через стрелку. Время засекал очень просто:

auto start = std::chrono::steady_clock::now();
std::vector<StudentInfo> studs(amount);
 
// do some..
 
auto end = std::chrono::steady_clock::now();
const std::chrono::duration<double> delta = end - start;
Теперь самое интересное - результаты. Вот они:


Как видно, вот всех дисциплинах убедительную победу одержал вариант с простыми структурами, значительно опередив вариант с raw-pointer и shared-pointer, причем сортировка была быстрее многократно, чтение значений быстрее на порядок, а удаление обьектов вообще в тысячи раз быстрее. Если ваши данные скомпонованы подряд, компактно в памяти и вы обращаетесь к ним напрямую, а не не через ссылку/указатель - то это путь к высокой скорости! Но нас интересует разница между сырыми указателями и умными указателями - она на самом деле оказалась не так и значительна: сортировка 10 миллионов элементов raw-pointer оказалась всего в 2 раза быстрее, чем сортировка shared-pointer. Создание и удаление и тех и других почти не отличается. 

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

P.S.
Код находится здесь. Тестовая платформа: Ryzen 7 3700X, 16GB RAM