Ускоряем многопоточную программу в 150 раз за три простых шага
Во вчерашней истории я, не задумываясь, предложил два способа решения проблемы congestion у Qt signal/slot в многопоточном случае: выдавать результат работы потока пачками и/или вообще выдавать его другим способом (через неблокирующую очередь).
Вчера вечером я проверил первый способ (сегодня задокументировал-нарисовал графики) и вот что у меня получилось:
Исходный псевдокод собственно обработчика выглядит так:
while(job = nextJob()){ result = processJob(job); emit processed(result); }
Давайте накопим результаты в пачку и выдадим эту пачку принимающей стороне:
while(job = nextJob()){ resultList.append(processJob(job)); if(resultList.size() > batchsz) { emit processedList(resultList); resultList.clear(); } } if(resultList.size() > 0) emit processedList(resultList);
Логика приложения несколько меняется: если у нас задачи поступают медленно, то выдача обработанных может тоже замедляться (пока не накопится batchsz), если это не /вполне/ подходит, то можно выдавать processedList или по накоплению или по таймеру, но вообще это все пока мелкие детали.
Берем и тестируем (я взял меньше чем вчера вариантов числа threads, а то задолбался с этими графиками):
Самый нижний график - тот же что вчера, только масштабы теперь логарифмические по обоим осям.
Да, батч дает некоторый результат (в полтора раза для 32 threads/32 batch size), но мы же ждали не этого? Мы ожидали ускорения раз эдак в 32 (для 32/32 случая)?
Лезем в профайлер и убеждаемся, что блокировка на QMutex на месте, просто это теперь другой QMutex, не на обработчике сигналов от threads, а на извлечении следующей задачи из очереди.
OK, а так же OK. Меняем QMutex на простейший спинлок, вот такой:
while (!atomic.testAndSetAcquire(0, 1)) YieldProcessor();
YieldProcessor() транслируется в nop; pause. Можно было просто _mm_pause() воткнуть, разницы никакой.
Смотрим результат:
Стало ну прям сильно лучше, для случая 32/32 - в 16 раз. Параллельная обработка на двух CPU уже не вредна (но и не полезна), ущерб от параллельной обработки на 32 ядрах - всего в 6 раз, а не в 30 как было раньше. Ну и для одного потока - батч в 32 результата в 4 раза лучше, чем по одному их фигачить.
Привычно лезем в профайлер, для случая 32/32 видим, что 86% CPU программа сидит в спинлоке.
OK, а так же ОК. Берем неблокирующую очередь. Я взял вот эту: github.com/cameron314/concurrentqueue потому что она header only, но судя по всему можно было бы взять любую. Компилируем, запускаем, меряем:
Стало ну не хорошо, но гораздо же лучше. Есть какая-то польза от многопоточности (32 потока работают в полтора раза быстрее, чем один, это УЖЕ РЕЗУЛЬТАТ), ну и на самом деле 2.6 миллиона rps меня, в общем, устраивают с огромным запасом (раз в 50-100).
Тем не менее, лезем в профайлер и находим там ТО С ЧЕГО НАЧИНАЛИ, опять тот же QMutex на обработке входящих сигналов от рабочих потоков. Он, конечно, в расчете на единицу работы жрет существенно меньше, но место подвигу еще, очевидно, есть.
В следующих сериях: мы же можем и выход от workers сунуть в такую же неблокирующую очередь. Это, правда, уже заметно поменяет логику: если сейчас у нас есть slot который позовут средствами Qt и сунут ему в зубы результат работы, то с очередью придется заводить consumer thread, как-то им управлять (ну, вероятно, он может сидеть в блокировке на queue.dequeue(), но нужно брать блокирующий вариант очереди) и вообще машинка сильно усложняется. Но я ее таки докую, интересно же.
Comments
Боже, как же ты мучаешься с
Боже, как же ты мучаешься с давно решённой задачей. Я понимаю, что тебя ограничивает Qt.
Но уже кажется для всего (по крайней мере, для всего модного, C++, увы, не моден нынче) написали (по мотивам Java ForkJoinPool'а — и не говорите, что Java медленная) work stealing almost-lock-free worker pool. Даже у Rust'а уже есть.
Есть ли то, что можно запустить в рамках Qt — не знаю. Для голого C++11 что-то находится.
Я же не мучаюсь, я у оного Qt
Я же не мучаюсь, я у оного Qt ищу ограничения.
На самом деле я в них не упирался до последнего момента, когда вычитку метадаты из RAW ускорили на порядок и вместо 300 файлов/сек на CPU можно распарсить ~3000 (если они уже в дисковом кэше, конечно).
Это еще не в production, но вот такое ускорение - не ускорило FRV в тех случаях, когда он читает только много метадаты.
Вот я и полез разбираться.
Ну и разобрался что 3000
Ну и разобрался что 3000/thread * 32 threads оно не может, а может тысяч 15 при таком числе потоков.
(сложно остановиться с
(сложно остановиться с комментированием)
У Qt есть "сахар", что вот можно позвать "callback" в контексте любого (другого) thread-а через тамошний signal/slot. Оно позовется или сразу (если велено так и только если это один thread) или вот через очереди, защищенные mutex (что - как выяснилось медленно, 15-100 тысяч/сек в зависимости от)
Этот вариант полностью хорош, пока событий на порядок/порядки меньше, ну и пусть значит живет.
А их меньше почти всегда: raw декодировать мы можем, ну десятки в секунду максимум. превьюшки - ну сотни.
А вот когда metadata only - тогда, да, нужен другой механизм, ну что ж.
Это интересный подход для Qt,
Это интересный подход для Qt, достаточно оригинальное мышление для поставленной задачи.
"если какое-то место работает
"если какое-то место работает плохо - не надо им пользоваться....", вот и вся оригинальность.