Обновить

Комментарии 29

 различие между методом at и operator[] у std::vector

изменения порядка циклов с ijk на ikj

Все бы хорошо, да вот только когда речь идет о перфомансе - примеры есть? Где микросекунды? Верить на слово?

В статье опишу "набор новичка"

Есть упоминание об инструменте - нет примера использования. С одной стороны какие-то уж очень общие знания, с другой - переключение на "углубленные" нюансы С++.

Верить на слово?

Спецификация at, [] есть в стандарте, в статье есть ссылка на недавнее выступление с cppcon про матричное умножение, ну или вот есть статья на хабре.

Есть упоминание об инструменте - нет примера использования

Ну, чем богаты.

У меня тоже сомнения. ARM архитектура, язык C (такие были всегда требования).
Проверял, *dst=*src работает быстрее, чем dst[i]=src[j]. Массивы с указателем data[i] внутри (ассемблер) не смотрел. Но если они работают с байтами, то у них должно быть более сложное устройство кода. А вот указатели - смотрел. С байтами они не работают, в частности, была ситуация, память могла работать только с 32-битными словами. Если на шину адреса выставить число, не кратное 4-м - то получал исключение. В таком случае выход такой - делается на Ассемблере, проверяется выравнивание (на 1, 2. 3. 0) и производится переход на метку для этого случая. Работаем с 32-разрядными адресами, считывам данные, потом выделяем из них нужные байты и т.д. Чтобы было быстрее, применяем команду PLD (preload). Так и системная memcpy работает (не со всеми SoC). Но оно того стоит - скорость выше.

А теперь - немного про caches. На днях запустил микротрансформер на "голом железе" на Raspberry Pi Zero 2W. (Cortex-A53, 64 бита, 4 ядра). Сначала без кешей, мне надо было код изменять раньше. Работало, медленно. Включил кеши и MMU. Вместо ожидаемых 10...20 крат прироста скорости, получились все 40. Вот тут я начал сомневаться, а что если процессор берет данные не из ячеек памяти, где они актуальные, а из кеша и помещает также, где гарантия, что они - валидны. Тут есть вариант либо чтения несколько раз, пока не считается нужное, либо надо ставить барьер в памяти. И то, и другое -лишние циклы. А если несколько ядер, у каждого свои кеши - как они могул знать, что у кого в "мозгаз" пока не попадут в RAM.
На "голом железе" легко можно включить/отключить кеши и сравнить. А в случае OC и Python? (Когда-то кеши можно было вклчать в BIOS, там же настраивать write-back, write-through). Были сообщения о дискредитации научных работ на Питоне, тогда списали на "разное представление чисел с плавающей запятой в разных процессорах". А что, если кеши виноваты - кто проверял.

P.S. На видео (RuTube) видно, как ядро 0 работает в сравнении с остальными:
https://rutube.ru/video/55c288c96846fd3402f6bf892df65f90/?r=a/

Отличная тема для статьи кстати.

Думал про это, но главное - не попасть впросак, Вы знаете, как тут бывает - засмеют и фамилию не спросят :)

"Если можешь не писать -- не пиши" Л. Н. Толстой.

Почему такая мелочь как "засмеют и фамилию не спросят" вас останавливает?

*dst=*src работает быстрее, чем dst[i]=src[j]. Массивы с указателем data[i] внутри (ассемблер) не смотрел

не совсем понял о чем речь

Включил кеши и MMU. Вместо ожидаемых 10...20 крат прироста скорости, получились все 40

Почему ожидали 10..20?

Вот тут я начал сомневаться, а что если процессор берет данные не из ячеек памяти, где они актуальные, а из кеша и помещает также, где гарантия, что они - валидны. Тут есть вариант либо чтения несколько раз, пока не считается нужное, либо надо ставить барьер в памяти. И то, и другое -лишние циклы. А если несколько ядер, у каждого свои кеши - как они могул знать, что у кого в "мозгаз" пока не попадут в RAM.

Как-будто вы описываете общую проблему валидности памяти кэш систем с разделенными L1/L2. Отсюда кстати и false sharing вырастает

Корректнее было сравнить dst[i]=src[j] с *(dst + i) = *(src + j).

Согласен. это было "образно". Думал написать *dst++=*src++ или dst++=some_data, но при вводе "*" тут текст становится курсивом, и это сбило с толку.. Речь, собственно о том, что при работе с указателями получалось быстрее, чем при работе с массивом с индексом.

Это в общем от компилятора зависит, если взять один и тот же код и скомпилять, скажем, Студией, интеловским OneAPI и gcc, то может быть быстрее и так и сяк, вот как раз интеловскому индексы могут "нравиться", ему с ними векторизовать легче. Это даже от версии компилятора может зависеть, прогресс на месте не стоит. Я обычно нагруженные циклы профилировщиком смотрю, и в принципе при некоторой "насмотренности" по ассемблерному коду уже более-менее видно, надо ли менять одну форму на другую или нет.

Вроде как раз для таких случаев godbolt.org полезен чтобы посмотреть что будет с -O2/-O3

Разве в C это не полный синоним по определению оператора []?

Я думал, что имелось в виду

for (int i = 0; i < N; ++i) {
  *(dst + i) = *(src + i);
}

и

while (src < end) {
  *dst++ = *src++;
}
#include <stdio.h>

int	i, number=777;

int	src_data [20000000] = {0,1,2,3,4,5,6,7,8,9, };
int	dst_data [20000000] = { };

int	*src_ptr, *dst_ptr;

int main(void)
{
    src_ptr = (int *)src_data;
    dst_ptr = (int *)dst_data;

    for	( i=0; i<20000000; i++)	{		dst_data [i] = src_data [i];	}
  	//for	( i=0; i<20000000; i++)	{		*dst_ptr++ = *src_ptr++;	}

	//dst_ptr = (int *)dst_data;
	//for	( i=0; i<20; i++)	{	number = dst_data [i];		printf("num: %d\n", number );	}

    return 0;
}

Примерно одинаковая скорость, если закомментировать строку 15 и раскомментировать строку 15. Точно - компилятор по своему делает, возможно, один и тот же ассемблерный код вставляет. Это на Intel. На ARM потом попробую. Раньше было так, что через указатели быстрее, но надо дихассемблировать.

Чтож вы мучаетесь то
https://godbolt.org/z/177jcYKc4

P. S. Для личного опыта интересно потыкаться, но для прода я бы не выпендривался и использовал std::copy или std::ranges::copy

Не мучаюсь. просто когда-то давно выяснял, что лучше. А C++ стараюсь не использовать (и требования часто такие), а в C - есть memcpy, я ее и внутри смотрел, и свою сделал, чтобы четко работала. (Иногда попадаются процессоры с несколько нестандатной памятью, например, которые только кратные адреса 4-м терпят на шине). А еще бывает, что на "голом железе" функции некоторые вообще не работают из стандартных библиотек или большие, приходится свои делать.

MS VC еще буквально в прошлом веке такое оптимизировал до одинакового кода, только цикл делал с N вниз, сравнивая счетчик с 0.

Сейчас не так важно что за SIMD 128-512 поддерживается, так как в железе может быть 4х 128битных пайплайна, то есть 4 инструкции выполняются параллельно. Такое сделано например на Intel E-ядрах и Cortex X4. А на AMD Zen4 был SIMD256 dual issue для эмуляции SIMD512 пока они не сделали полноценные 512 бит на Zen5.

Очень сильно не согласен. Во-первых, SSE, AVX2 и AVX512 различаются не только длиной регистра, но и доступными инструкциями (вики и список интринсиков). Во-вторых, в зависимости от задачи ботлнек может быть по памяти, а может и по вычислениям, в последнем случае имеет значение сколько и каких SIMD юнитов имеет процессор. У меня есть пример где ботлнек по арифметике и как раз использование GFNI из AVX-512 ускоряет в два раза универсальный алгоритм на AVX2
https://github.com/Malkovsky/galois?tab=readme-ov-file#vector-operations
(понятно, что оба варианта еще можно соптимизировать и разница возможно будет меньше, но суть от этого не меняется)

Под AVX тоже есть расширения, например AVX-VNNI для Intel, который повторяет AVX512-VNNI.

Я все же про то, что решает распараллеливание инструкций, а не длина SIMD.

Ещё, кстати, AVX 512 может заметно дропать частоту ядер (ему просто больше транзисторов в моменте переключать надо), и по итогу выигрыш заметно меньше ожидаемого. Я как-то кучу времени угрохал, перекладывая AVX2 на 512 (команды там и правда разные и иногда просто тупой сменой ширины регистров не обойтись) и был сильно разочарован приростом, в два раза там и близко не было.

Ещё, кстати, AVX 512 может заметно дропать частоту ядер (ему просто больше транзисторов в моменте переключать надо)

Хмм, я мне говорили, что он это делает, потому что первые релизы плавили процессор.

Может и так, я где-то читал, как оно там устроено, он температуру не меряет, а просто смотрит на команды, сколько их через сколько ядер летит и соответственно частоту снижает, там просто таблицы, то есть это не термальный троттллинг, который в теории может и на успеть. Хасвелл вот и на командах AVX2 до максима не бустился.

Паттерны доступа к памяти сейчас важнее, чем количество тактов на арифметическую инструкцию. CPU считает быстро, но ждет данные из RAM целую вечность. AoS vs SoA - вот где зарыты реальные иксы производительности

Да я вроде этому и посвятил пол статьи. Но всё-таки это не всегда так и надо отслеживать когда I/O bound а когда CPU. AoS vs SoA это частные случае data oriented design, минимизация обращений к памяти через дизайн структуры часто очень не простая задача

А архитектура CPU — совсем-совсем без разницы (т.е. примеры и соображения в статье не зависят от того, выполняются они на amd64/ARM/RISC-V)?

Разница в первую очередь в наборах инструкций, AMD64 и x86-64 в этом плане примерно одно и тоже, но у AMD поддержка новых расширений частенько с запозданием. ARM и RISC-V -- это семейство RISC, у ARM свои SIMD (ссылка есть в статье), с RISC-V совсем не работал, поэтому гадать не буду -- лучше поищите сами.

Референсные значения по таймингам доступа к кэшам/регистрам схожи, но если вы оптимизируете под конкретное устройство, то нужно смотреть референс этого конкретного устройства.

result = condition if_expression + !condition else_expression;

кстати в java и варинат "condition ? if_expression : else_expression" работает, компилируется в conditional move, наверно и  других языках похожая ситуация. это я к тому, что выражение "? : " совсем не обязательно компилируется в чистый if, выглядит не так жутко как предложенный вариант и работает с такой же скоростью

Да, это "тернарный оператор", в С/С++ он отоже есть. Честно говоря не знаю есть ли в нем большой смысл с современными компиляторами, как синтаксический сахар точно полезен.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации