пятница, 28 июня 2024 г.

[prog.c++] Пример того, что могут творить оптимизирующие C++ компиляторы

В догонку к предыдущему посту про скорость самодельного UTF-8 валидатора. Захотелось добавить в это же сравнение и is_utf8 на базе SIMD.

Задействовать is_utf8 несложно, пишется очередная check_validity_with_*:

bool
check_validity_with_is_utf8_simd(std::string_view str)
{
   return is_utf8( str.data(), str.size() );
}

И все.

Но не совсем, т.к. эта новая check_validity только возвращает true/false, тогда как старые реализации еще и суммировали извлеченные code-point-ы:

bool
check_validity_with_restinio(std::string_view str, std::uint32_t & out)
{
   restinio::utils::utf8_checker_t checker;
   forconst auto ch : str )
   {
      if( checker.process_byte( ch ) )
      {
         if( checker.finalized() )
            out += checker.current_symbol();
      }
      else
         return false;
   }

   return true;
}

Т.е. делали лишнюю работу. А раз работа лишняя, то от нее хорошо было бы избавиться:

bool
check_validity_with_restinio(std::string_view str)
{
   restinio::utils::utf8_checker_t checker;
   forconst auto ch : str )
   {
      if( !checker.process_byte( static_cast<unsigned char>(ch) ) )
      {
         return false;
      }
   }

   return true;
}

OK, сделано. Можно запускать бенчмарк и...

И внезапно я вижу какие-то нереальные цифры:

***     restinio: 8us ***
***  decode_2009: 12us ***
***  decode_2010: 11us ***
*** simd_is_utf8: 49176us ***

Это GCC-13.2 под Windows на i7-8550u, ключи компиляции:

g++ -O2 -std=c++20 -o utf8_checker_speed-gcc13.exe *.cpp

В общем, явно что-то не то, ну не может работа выполняться за 8us там, где раньше было около секунды. Но что именно не то?

Оказалось, что компилятор GCC-13 настолько продвинут, что смог обнаружить отсутствие побочных эффектов в вызове обновленных версий check_validity_with_restinio, check_validity_with_decode_2009, check_validity_with_decode_2010. И ведь действительно, побочных эффектов там нет: на вход подаются одни и те же данные, на основе одних и тех же данных выполняются одни и те же вычисления. Соответственно, и результат вычислений всегда будет один и тот же.

Ну а раз результат всегда один и тот же, то нет смысла вызывать функции check_validity_with_restinio 100k раз, достаточно всего одного раза. Что GCC и сделал.

Отсюда и такие маленькие цифры -- это замер для всего одного вызова check_validity_with_*. А не для 100K вызовов, как задумывалось.

Тогда как в случае с is_utf8 компилятор не смог сделать выводов об отсутствии побочных эффектов (полагаю потому, что сама is_utf8 находилась в другой единице трансляции) и вызов check_validity_with_is_utf8_simd честно выполнялся 100K раз.

Чтобы понять что происходит пришлось заглянуть в ассемблерный выхлоп компилятора, чего я уже не делал лет сто, если не сто пятьдесят ;)

Еще прикольнее то, что GCC-11 не разобрался с отсутствием побочных эффектов у check_validity_with_restinio и честно вызывал ее 100k раз, тогда как вызовы check_validity_with_decode_2009 и check_validity_with_decode_2010 успешно "заоптимизировал". А вот GCC-13 смог справиться даже с utf8_checker_t из RESTinio. Что для меня выглядит ну совсем уже фантастикой.

Нужно сказать, что только GCC смог в такую крутую "оптимизацию". Ни с VC++, ни с clang ничего подобного не было.

PS. Если кому-то интересно, то вот результаты GCC-13.2 когда заставляешь его делать все 100k вызовов:

***     restinio: 535279us ***
***  decode_2009: 1197288us ***
***  decode_2010: 1137498us ***
*** simd_is_utf8: 53430us ***

вторник, 25 июня 2024 г.

[prog.c++] Любопытное на тему производительности самодельного проверяльщика UTF-8

Довелось недавно столкнуться с задачей проверки корректности UTF-8 представления. И в рамках этой задачи вышел вот на это замечательное описание с примерами двух реализаций: Flexible and Economical UTF-8 Decoder.

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

Поскольку у нас в RESTinio есть что-то аналогичное, но гораздо более объемное по числу строк, то подумалось, что можно было бы заменить наш многострочный utf8_checker на более компактный.

Но прежде чем сделать это попробовал провести простейшие сравнения производительности. И вот тут-то меня ждал сюрприз...

На основном рабочем Windows-ноутбуке с i7-8550U и VisualStudio 2022 (17.10.3) примитивный бенчмарк показывает следующие результаты:

***    restinio: 1240136us ***
*** decode_2009: 1784796us ***
*** decode_2010: 1774765us ***

Компиляция выполнялась так: cl -EHsc -O2 -DNDEBUG -utf-8 -std:c++20

На этом же Windows-ноутбуке под GCC-13.2.0 (из MinGW-w64) получаются следующие числа:

***    restinio: 435519us ***
*** decode_2009: 977052us ***
*** decode_2010: 566962us ***

Компиляция выполнялась так: g++ -O2 -std=c++20

На резервном Linux-ноутбуке с i7-6600U и GCC-13.1 этот же бенчмарк дает следующие числа:

***    restinio: 915891us ***  
*** decode_2009: 873742us ***  
*** decode_2010: 809853us ***

Но еще интереснее оказывается с GCC-11 на том же Linux-ноутбуке:

***    restinio: 941621us ***  
*** decode_2009: 1857220us ***  
*** decode_2010: 2047872us ***

Т.е., похоже, в GCC-13 оптимизатор серьезно прокачали, отсюда и гораздо лучшие результаты для решений от 2009-го и 2010-го годов под GCC-13.

Но намного больше пищи для размышлений дал clang-18:

***    restinio: 2108439us ***  
*** decode_2009: 1775339us ***  
*** decode_2010: 2012727us ***

Т.е. оптимизатор в clang-е с решениями 2009-го и 2010-го годов справился на уровне GCC-11, но вот наш код из RESTinio он настолько же сильно, как GCC-13, оптимизировать не смог.

Ну и для полноты картины результаты clang-16 с того же Linux-ноутбука:

***    restinio: 2379565us ***  
*** decode_2009: 3222073us ***  
*** decode_2010: 1839321us ***

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

Меня удивило другое: насколько разные результаты можно получить на одной и той же платформе с разными версиями одного и того же компилятора (не говоря уже о разных компиляторах).

Если взять специфику наших открытых библиотек, которые распространяются в исходниках, а затем компилируются на неизвестно каких платформах неизвестно какими компиляторами, то насколько уместно заниматься низкоуровневыми оптимизациями? Типа отшлифуешь все до последней микросекунды на Windows с VC++, а на Linux-е и 11-ом GCC, напротив, получишь просадку производительности.

PS. Я понимаю, что это негодный бенчмарк. Всего один набор тестовых данных. Весь код в одном файле, что не есть хорошо, если разнести функции по разным .cpp-файлам результаты могут отличаться, например, когда каждая функция декодирования помещается в отдельный C++ файл, то результы с GCC-13 под Linux-ом на i7-6600U меняются в пользу реализации от RESTinio:

***      restinio: 612732us ***
***   decode_2010: 820765us ***  
***   decode_2009: 855889us ***  

Но для осознания глубины кроличьей норы, имхо, сойдет даже такое примитивное сравнение 🙂