О семантике C++

В продолжение кулуарного разговора с Highload++, навеянного 26-м слайдом презентации Андрея Аксенова.

Вот такой вот код:

class someShit{
        char *m_sBuffer;
        size_t m_iLimit, m_iCounter;
};
void someShit::try1()
{
        m_iCounter = 0;
        while (m_iCounter< m_iLimit && m_sBuffer[m_iCounter])
                m_iCounter++;
}

void someShit::try2()
{
        size_t l_iCounter = 0;
        while (l_iCounter< m_iLimit && m_sBuffer[l_iCounter])
                l_iCounter++;
        m_iCounter = l_iCounter;
}
Компиляторы (смотрел gcc 4.6, Visual Studio 2010 и Intel 11.0, все с включенной стандартной оптимизацией -O3/O2 без тонких настроек) делают разное:
  • gcc и Visual Studio генерируют код с той семантикой, какой написано;
  • Intel C++ делает так же, если компилирует "библиотеку", но оптимизирует первый случай до второго, если все потроха (методы класса и вызывающий их main()) лежат в одном файле.

Ассемблерный код получается, по смыслу, такой вот (это MSVC-шный, как самый читаемый):

  • В первом случае инкрементруем счетчик прямо в памяти:
    $LL2@try1:
            mov     ecx, DWORD PTR [eax+8]
            cmp     BYTE PTR [edx+ecx], 0
            je      SHORT $LN1@try1
            inc     ecx
            mov     DWORD PTR [eax+8], ecx
            cmp     ecx, DWORD PTR [eax+4]
            jb      SHORT $LL2@try1
    $LN1@try1:
  • Второй вариант инкрементирует регистр, а в память пишет на выходе:
    $LL2@try2:
            cmp     BYTE PTR [edx+eax], 0
            je      SHORT $LN8@try2
            inc     eax
            cmp     eax, DWORD PTR [ecx+4]
            jb      SHORT $LL2@try2
    $LN8@try2:
            mov     DWORD PTR [ecx+8], eax

Понятно что второй способ заметно эффективнее. Я не мерял, а Андрей намерял 1.75 раза разницы.

Вопросов никаких почти нет, кроме вот какого:

"Высокоуровневый ассемблер" делает "что сказал программист". Т.е. неявно предполагается, что какой-то другой thread (скажем, на другом CPU) может ВНЕЗАПНО, прямо вот в процессе выполнения цикла, поинтересоваться значением поля m_iCounter. Ну, типа, да, многопоточность, многопроцессорность, 21-й век. Интеловский компилятор, впрочем, умееет неявно догадаться, что ничего такого не будет, видя код main().

А вопрос такой: как бы сообщить компилятору явно, что никакого произвольного доступа не планируется (или, если и будет, результат его неважен) и что можно полноценно оптимизировать в регистрах, весь объект полностью в распоряжении. Хорошо бы еще сказать это переносимым способом (gcc/llvm/msvc/intel c++).

Comments

Вообще это какой-то жесткач. В общем случае другой тред может не увидеть значение m_icounter даже если и через память всё работает (кэши, упорядоченность записи в пямять и прочие барьеры).
Компилироваться как в первом случае должно только если volatile явно указано.

Что же это получается, регистры вообще нельзя использовать для глобальных, статических и просто членов классов? Что-то тут не так.

Во-во. Ятакдумаю, что афтары MSVC et. al. просто еще не очень научились писать достаточно оптимизирующие компиляторы :-)

А кто научился?

Я попробовал три компилятора, которые были на винде - все три ведут себя одинаково (с оговоркой про Intel, который во многом подобном был замечен и ранее, например он некоторые мои бенчмарки оптимизировал до полного исчезновения т.к. видел что результат не используется).

Может не научились, а наоборот? Ну типа кэши сейчас быстрые и т.д. если переменных не много, можно не париться, процессор сам внутри сделает?)

А чего он такого сделает?
Аксенов же померял, разница в 1.75 раза, дохрена.

я к тому, что нет ответа на ваш вопрос просто плохой компилятор. Не думаю, что есть предположения про второй тред. Стандарт явно говорит про volаtile. Вряд ли в GCC решили сознательно автоматом проставить volatile (а он на самом деле не про треды:) на все переменные члены класса в угоду плохим программистам.
Мне вот кажется на всяких классических RISC-ах первый вариант был бы ещё хуже чем на современных интелах. И вероятно GCC раньше так не делал.

Я бы согласился на "просто плохой компилятор", если бы не увидел у Интела в этом месте разумного поведения, зависящего от контекста.

Тогда для чистоты эксперимента нужно и с локальными переменными тест провести. Вставить после:
size_t l_iCounter = 0;
что-то типа
external_call(&l_iCounter);
Изменится поведение у интела?

если интел будет продолжать "видеть всю программу", то скорее всего все останется так же (с жизнью с регистром), но я попробую вечерком.

Ну вот интел научился. Хотя я кажись в своих экспериментах несколько раз получил совершенно обратный результат - интел (девятый?) позорно облажался, а мелкософт выступил очень хорошо. Я тогда собственно бросил упражняться с компиляторами, решил что мелкософт - good enough for everybody, а что не гуд - руками перепишем.

Интел научился только если он видит весь контекст исполнения. Он много что в таком случае умеет, например узнать что данные получены через _mm_malloc и можно их грузить movaps, а не movups, много такого хорошего он умеет.

Но в реальной жизни, когда объектник компилируются, чтобы лечь в .DLL, которую будет неизвестно кто и неизвестно какими данными, в данном примере получается одинаковое говно.
И мой вопрос именно про то, как его избежать, сказав компилятору петушиное слово.

Да, по идее первый код - это именно volatile т.к. оно еще и перечитывает на каждой итерации.

И все будет работать по меньшей мере частично т.к. процессоры/ядра обмениваются информацией о протухших адресах в кэше и re-read будет, как минимум, читать из памяти.

Когда кэш сбрасывается - я никогда ранее не задумывался, оказывается есть инструкции и для целого кэша и для отдельного адреса....

вот именно "частично". Я вот другого не понимаю если не пользоваться примитивами синхронизации (включая atomic функции) ведь ничего работать сейчас не будет. А если пользоваться, то всё будет хорошо, главное чтобы компилятор сбрасывал в пямять данные перед вызовами и возвратами функций.

В смысле, тогда эти неявные volatile и не нужны совсем.

Именно!

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

Если мне зачем-то нужен постоянный сброс из регистра память - ну алле, в стандарте для этого уже 100 лет, как придумано отличное слово volatile.

Но нет - искуственный интеллект зачем-то делает неявный volatile для члена класса.

volatile - это re-read перед каждым обращением, про write не написано :)

Но полностью разделяю негодование!

write сам собой вытекает, без него мало смысла в read.

И вообще, оно чуть ли не для интеракции с портами было введено, емнип :)

ну да, с memory mapped io (вроде как на старых санах), оно конечно имеет смысл: записать по адресу 7, а потом 11 может иметь другой смысл, чем просто 11.

Синхронизации нужны если несколько потоков пишут в одну location. Это без вопросов.

А если второй поток только читает - то он получит какое-то значение. Так как без синхронизации - неизвестно в какую итерацию цикла он попадет, то любое значение ему подойдет.

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

Уже запись 64 бит (size_t в вышеуказанном примере) не атомарна на интеловской архитектуре. И эти наполовину перезаписанные 64 бита ловятся на практике.

В данном примере size_t 32-бита (т.к. код 32-битный).

Но вообще чтение чего-то, что быстро-быстро меняется вот прямо под пальцами - не имеет смысла. Использовать это значение как-то внятно нельзя.

Может везде явно ему намекать чтобы он переменную в регистре держал. И будет как бы явный не volatile. А компилятор уже либо сможет регистр использовать, либо нет.

А как это сделать для переменной - члена класса?

наверное никак. Даже если я вообще переменную объявляю и не используют, её вряд ли какой-то компилятор трогать станет или удалять (у разработчиков компиляторов, какая-то фобия на эту ему)..

В классе объявляете?

Ну еще бы не фобия, она может быть объявлена, к примеру, для правильного выравнивания.

У интела более серъёзная математика в компиляторе, ему надо всякие MMX вместо циклов ставить и т.д..

А MSVC все эти изыски поддерживать не обязан. И вообще там, помойму до сих пор можно встретить ситуации когда он сохраняет какую-то переменную в память и тут же следом её загружает. Что-нибудь не такое явное, но похожее по смыслу:
mov [ebp+4],eax
mov eax,[ebp+4]

наверняка он просто код нужный сгенерить не в состоянии и проще ему цикл руками написать как надо..

2008-я и 10-я студии - неплохие компиляторы. Да, формально интел умеет больше (векторизация, параллелизация), а на практике - нет.

Явного модификатора volatile на этом члене класса простите, нету.

В стандарте ничего про гарантии доступности из соседних тредов, простите, нету.

Там вообще слова "тред", простите, нету.

Многопоточные ошибки такие фокусы, простите, не исправляют. А наоборот, заметают под ковер и усложняют поимку и починку.

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

Я ни разу не говорил компилятору, что переменная volatile, и я хочу новое значение в памяти немедленно после каждой операции. Компилятор делает НЕ то, что я ему сказал!

Тут же вот какая беда: есть оптимальное поведение (и в данном случае это было бы "закэшировать в регистре"), а есть "рассчитанное на плохую ситуацию".

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

Но я хочу иметь возможность это отключить.

Схожая история уже обсуждалась тут: http://blog.lexa.ru/2010/12/27/o_stepennoi_funktsii_openmp_i_prochikh_gr...
В VS2010 в OpenMP добавили спинлок, для синхронизации после завершения всех тредов. Отчего стало больно в тех случаях, когда worker-ы мелкие, оверхед большой. Зато какое-то количество ошибок исчезло.
Интел его тоже добавил, но сделал регулируемым.

данный пример к многопоточности отношения не имеет, это просто кривость или баг компилятора. компилятор по умолчанию генерирует код для однопоточного случая, применяет все известные оптимизации и не заботится об их корректности, это должен делать программист, если нужно.
попробуйте переписать в виде цикла for:
for(m_iCounter = 0; m_iCounter < m_iLimit && m_sBuffer[m_iCounter]; m_iCounter++) ;

Ну вот Intel C++ явно делает ЭТО как-то осмысленно.

Вообще это довольно древний момент, я не уверен что применимо в конкретном случае, но в целом - вот:
http://msdn.microsoft.com/en-us/library/aa984741(v=vs.71).aspx

Алиасинг - это совсем не то, это предположение, что два разных указателя могут, на самом деле, указывать в одну и ту же memory location и, соответственно, оптимизировать надо аккуратнее.

Но это может быть отрыжка от чего-то такого, да.

В смысле - два указателя в одном локальном куске кода.

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

Это pointer aliasing. Второй указатель - это this

А первый?

m_sBuffer

В смысле, компилятор закладывается на то, что счетчик (который и берется из памяти) может оказаться внутри буфера?

Интересная мысля, заменю указатель на массив...

Ага, оно.

Если m_sBuffer определить как char[10k], то интел умело начинает работать с регистром. Гы.

Сейчас пост про это напишу....

> Т.е. неявно предполагается, что какой-то другой thread (скажем, на другом
> CPU) может ВНЕЗАПНО, прямо вот в процессе выполнения цикла,
> поинтересоваться значением поля m_iCounter.

Дык, а нахрена придумали слово volatile ?

> А вопрос такой: как бы сообщить компилятору явно, что никакого произвольного доступа не планируется

Дык, именно это и должно подразумеваться по умолчанию, а для всех остальных случаев есть volatile .

Кстати, для arm'ов RVCT вполне оптимизирует такую фигню и локализует переменуную в регистре с отложенным сбросом окончательного значения в память. А гэцеце - фиг.

Повторю аргумент, который уже тут всплывал по треду.

Интел умеет это оптимизировать. Но делает это только в том случае, когда видит "все приложение" (т.е. в один исходник с try1/try2 поместить еще и main(), который их вызывает).
А если main() вынести в отдельный файл - то и у интела остается работа через память.

Т.е. дело не в умении компилятора, а в каких-то assumptions, про которые я и пытаюсь спросить :)

> Т.е. дело не в умении компилятора, а в каких-то assumptions, про которые я и пытаюсь спросить :)

Так в том то и дело, что какие-то это странные предположения.
У RVCT вроде нет таких предположений и если не написано volatile, то будет пытаться держать в регистрах. Я видел когда, когда несколько полей структуры были полностью загружены в регистры в начале функции в регистры, а потом сброшены те что поменялись.

Я, как положено приверженцу conspiracy theory, подразумеваю тут заговор.
Осталось выяснить, в чем же он заключается.

Т.е. компилятор могет, но не делает. И мне кажется что не по дурости не делает, а с каким-то умыслом.