- PVSM.RU - https://www.pvsm.ru -
Сегодня мы будем измерять производительность разных реализаций функции toupper, ведь именно этим и занимаются по вторникам.
Вообще-то мне нет никакого дела до функции toupper [1], просто я недавно писал другой пост и мне нужен был какой-то общий сюжетный стержень, а toupper кажется вполне интересным и безобидным кандидатом в бенчмарки. Я старался выбрать что-то максимально простое, что не увело бы меня в сторону, но так уж получилось, что в этом тесте я столкнулся со странной проблемой.
Этот пост будет небольшим – более обстоятельная статья на первоначальную, возможно более интересную тему ожидается в скором времени. Если захотите воспроизводить результаты вместе со мной, то исходный код можете взять на github [2].
Итак, рассмотрим три реализации функции toupper, которая переводит в верхний регистр символы некоторого массива, состоящего из элементов типа char, – а именно, принимает массив в качестве аргумента и непосредственно изменяет его элементы так, что все строчные буквы обращаются в прописные.
В первой реализации мы просто вызываем функцию toupper [3] [1] стандартной библиотеки C и выполняем цикл в стиле C:
void toupper_rawloop(char* buf, size_t size) {
for (size_t i = 0; i < size; i++) {
buf[i] = toupper(buf[i]);
}
}
Во второй реализации используем более современный [4] подход с заменой «сырого» цикла на std::transform:
void toupper_transform(char* buf, size_t size) {
std::transform(buf, buf + size, buf, ::toupper);
}
Наконец, в третьей реализации используем специальный алгоритм, работающий с ASCII- символами. Он проверяет, входит ли символ в диапазон a – z, и в случае успешной проверки подставляет эту же букву в верхнем регистре, вычитая из кода символа число 32 [2]:
void toupper_branch(char* buf, size_t size) {
for (size_t i = 0; i < size; i++) {
char c = buf[i];
if (c >= 'a' && c <= 'z') {
buf[i] = c - 32;
}
}
}
Выглядит просто, правда?
Теперь измерим скорость работы этих реализаций на моём ноутбуке с процессором Skylake i7-6700HQ на компиляторе gcc 5.5 с настройками по умолчанию. Результаты даны в виде точечной диаграммы [3]:

Сразу разберёмся с тремя вопросами, которые неактуальны для нашей задачи.
Во-первых, посмотрите на график алгоритма с ветвлением (показан зелёным). Он значительно изменяется в зависимости от размера входных данных – другие два графика остаются практически ровными. На самом деле это всего лишь артефакт тестирования. Входные ASCII-символы выбираются случайным образом [4], поэтому решающим фактором в случае третьей реализации оказывается работа алгоритма предсказания ветвления. При малом количестве данных он целиком заучивает последовательность элементов по мере выполнения итераций, поэтому количество промахов невелико и скорость работы высокая, как показано в этой заметке [5]. По мере роста размера последовательности данных алгоритм предсказания запоминает все меньше и меньше, пока наконец не начинает промахиваться с каждой прописной буквой (0,27 промахов на символ), и тогда график выравнивается.
Во-вторых, обратите внимание на группу зелёных пятнышек слева вверху, соответствующих намного более низким скоростям варианта с ветвлением toupper_branch:

Это не единичный артефакт: такие пятнышки появлялись при нескольких запусках. При этом их нельзя воспроизвести, если тестировать алгоритм только конкретно на этих размерах данных – они всплывают, только когда тест прогоняется по всем размерам. Но и в этом случае они появляются не всегда. Я не стал особо вникать, но могу предположить, что это связано с какими-то конфликтами имён или псевдонимов в алгоритме предсказания ветвления или при отображении физических страниц памяти размером 4 кБ на виртуальные (хотя рандомизация виртуального адресного пространства у меня была выключена).
В-третьих, реализация toupper_rawloop (показана синим) на графике выглядит как две отдельных линии: одна чуть выше отметки 2 тактов на символ, а другая – на уровне 1,5 тактов на символ. Эти две линии появлялись во всех тестировщиках. Более быстрый вариант, со скоростью 1,57 символов на такт, на самом деле тормозится на портах загрузки: чтение данных на портах 2 и 3 происходит со скоростью 1,54 микрооперации на такт, поэтому они будут заняты на 98%. Причину же более медленного «режима» я установить не смог.
Пока я разбирался с этим вопросом, быстрый «режим» внезапно исчез и остался только медленный. Возможно, процессор смекнул, что я пытаюсь сделать, и тайно скачал обновление для микрокода, чтобы убрать противоречие, но у меня (пока ещё) есть доказательство – векторное изображение с графиками.
Что же нас тогда интересует в этом примере?
А интересует нас то, что версия с «сырым» циклом в 3-4 раза быстрее версии с std::transform: 1,5-2 такта на символ против 7 с небольшим тактов на символ.
В чём же тут дело? Меня подвели стандартные алгоритмы? У std::transform есть какой-то изъян?
Не совсем. Точнее, вовсе нет.
Оказывается, такие результаты появляются, когда функции компилируются в разных файлах [6]. Если поместить их в один и тот же файл, то производительность у них становится одинаково низкой.
И нет, выравнивание тут не при чём.
Но это ещё не всё: быстрая версия с «сырым» циклом, будучи скомпилированной в отдельном файле, замедляется, если к нему просто подключить заголовочный файл <algorithm>. Да, всё верно: достаточно подключить этот файл, который никогда не используется и не генерирует никакого кода в итоговом объектном файле, и скорость «сырого» цикла упадёт в 3-4 раза. Напротив, версия с std::transform разгоняется до предела, если скопировать и вставить реализацию std::transform из файла <algorithm>, а сам этот файл не подключать.
На этом странности не заканчиваются (больше их не будет, обещаю): подключение файла <algorithm> не всегда приводит к описанному эффекту. Падение скорости происходит, если <algorithm> подключается раньше, чем <ctype.h>, но если поменять их местами – то нет:
Медленный код:
#include <algorithm>
#include <ctype.h>
Быстрый код:
#include <ctype.h>
#include <algorithm>
Собственно, у меня эта аномалия проявлялась (в другом проекте), когда clang-format автоматически сортировал подключаемые заголовочные файлы и помещал <algorithm> в самое начало списка, где ему и место (отсюда и кликбейт-заголовок статьи).
Естественно, мы должны были рано или поздно окунуться в листинг ассемблера. Не будем оттягивать этот неприятный момент.
Ниже показаны быстрая и медленная [7] версии функций [5], малые циклы снабжены аннотациями:
<algorithm> подключается первым:
toupper_rawloop(char*, unsigned long):
push rbp
push rbx
lea rbp, [rdi+rsi]
sub rsp, 8
test rsi, rsi
je .L1
mov rbx, rdi
.L5:
movsx edi, BYTE PTR [rbx] ; читаем char-символ из *buf
add rbx, 1 ; buf++
call toupper ; вызываем toupper(c)
mov BYTE PTR [rbx-1], al ; сохраняем результат в buf[-1]
cmp rbp, rbx ; проверяем buf == buf_end
jne .L5 ;
.L1:
add rsp, 8
pop rbx
pop rbp
ret
<algorithm> подключается вторым:
toupper_rawloop(char*, unsigned long):
test rsi, rsi
je .L7
push rbp
push rbx
mov rbp, rsi
mov rbx, rdi
sub rsp, 8
call __ctype_toupper_loc
lea rsi, [rbx+rbp]
mov rdi, rbx
.L4:
; читаем char-символ из buf
movsx rcx, BYTE PTR [rdi]
; читаем адрес таблицы соответствия для toupper
; (по указателю из __ctype_toupper_loc)
mov rdx, QWORD PTR [rax]
; buf++
add rdi, 1
; ищем результат toupper в таблице,
; используя считанный символ в качестве индекса
mov edx, DWORD PTR [rdx+rcx*4]
mov BYTE PTR [rdi-1], dl ; сохраняем результат
cmp rsi, rdi ; проверяем buf == end_buf
jne .L4 ;
add rsp, 8
pop rbx
pop rbp
.L7:
rep ret
Главное отличие состоит в том, что в медленной версии функция toupper просто вызывается в цикле, тогда как в быстрой версии вызовы функции вовсе отсутствуют, а есть только поиск по таблице соответствия [6], т.е. тело функции std::toupper подставляется на место вызова.
Если посмотреть на исходный код [8] библиотеки glibc, мы найдём там реализацию функции toupper:
__extern_inline int
// __NTH – это макрос, показывающий, что функция не бросает исключений
__NTH (toupper (int __c))
{
return __c >= -128 &&
__c < 256 ?
(*__ctype_toupper_loc ())[__c] : __c;
}
Как мы видим, toupper определена как extern inline функция, которая сначала проверяет, что размер char-символа умещается в один байт [7], а затем ищет символ в таблице соответствия, возвращаемой функцией __ctype_toupper_loc(). Эта функция возвращает локальный указатель потока (типа const int **), который, в свою очередь, указывает на таблицу соответствия, из которой в ответ на запрос по нашему символу возвращается его версия в верхнем регистре [8].
Теперь понятно, что происходит в листинге. В быстрой версии алгоритма компилятор подставляет тело функции toupper, но не может подставить вызов функции __ctype_toupper_loc() [9]. Этот вызов, однако, объявлен как __attribute__((const)), а это значит, что возвращаемое значение зависит только от аргументов (которых здесь нет). Компилятор знает, что эта функция каждый раз возвращает одно и то же значение, и поэтому выносит её вызов за пределы цикла, а в самом цикле остаётся только несколько операций чтения, связанных с обращением к таблице соответствия, записью нового значения в буфер и управлением циклом.
В медленной же версии вызов toupper() остаётся в теле цикла. Сам цикл короче на одну команду, но, разумеется, теперь ещё приходится выполнять и весь код внутри функции toupper. На моей системе это выглядит так:
lea edx,[rdi+0x80] ; edx = rdi + 0x80
movsxd rax,edi ; дополнение нулями переменной c
cmp edx,0x17f ; проверяем, что c входит в диапазон от -128 до 255
ja 2a ; если нет, завершаем работу
mov rdx,QWORD PTR [rip+0x395f30] ; считываем индекс локального
; хранилища потока
mov rdx,QWORD PTR fs:[rdx] ; обращаемся к локальному хранилищу
; потока по данному индексу
mov rdx,QWORD PTR [rdx] ; разыменовываем указатель на
; локальное хранилище потока
mov rdx,QWORD PTR [rdx+0x48] ; считываем текущую таблицу для toupper
mov eax,DWORD PTR [rdx+rax*4+0x200] ; считываем c из таблицы
2a:
ret
Поскольку это невстроенный вызов, программа совершает больше работы. Происходит не менее пяти последовательных операций обращения к памяти (так называемая погоня за указателями, pointer chasing). В быстрой версии из них остаются только две, так как все остальные выносятся за пределы цикла. Задержка между вызовом функции и выходом из неё должна быть около 25 тактов, а у нас выходит примерно 7 тактов – это означает, что процессор смог распараллелить вызов, что весьма неплохо, учитывая обстоятельства.
Почему так получается?
В длинной цепи подключаемых файлов заголовочные файлы C++, такие как <algorithm>, включают, в свою очередь, файл <bits/os_defines.h>, в котором содержится следующая строка:
// Эта директива не даёт функции isanum и др. воспроизводиться
// в качестве макросов.
#define __NO_CTYPE 1
Когда наконец подключается файл <ctype.h>, из-за этой директивы код, в котором toupper определена как extern inline, не может быть включён:
#if !defined __NO_CTYPE
# ifdef __isctype_f
__isctype_f (alnum)
// и т.д. и т.п.
__isctype_f (xdigit)
# elif defined __isctype
# define isalnum(c) __isctype((c), _ISalnum)
# define isalpha(c) __isctype((c), _ISalpha)
// и т.д. и т.п.
# endif
// нас интересует вот эта часть
# ifdef __USE_EXTERN_INLINES
__extern_inline int
__NTH (tolower (int __c))
{
return __c >= -128 && __c < 256 ? (*__ctype_tolower_loc ())[__c] : __c;
}
__extern_inline int
__NTH (toupper (int __c))
{
return __c >= -128 && __c < 256 ? (*__ctype_toupper_loc ())[__c] : __c;
}
# endif
// вот здесь tolower определена в качестве макроса
# if __GNUC__ >= 2 && defined __OPTIMIZE__ && !defined __cplusplus
# define tolower(c) __tobody (c, tolower, *__ctype_tolower_loc (), (c))
# define toupper(c) __tobody (c, toupper, *__ctype_toupper_loc (), (c))
# endif /* Optimizing gcc */
#endif /* Not __NO_CTYPE. */
Обратите внимание, что при подключении <ctype.h> C++-версия toupper никогда не определяется в качестве макроса – максимум как extern inline – так как определения макросов в конце защищены проверкой !defined __cplusplus и поэтому никогда не вступят в силу.
В общем, я не знаю наверняка, должен ли __NO_CTYPE в данном случае исключать объявленные как extern inline тела функций tolower и toupper, но именно это и происходит – а отсюда и существенное падение скорости нашего цикла. В заключение могу сказать, что если вы включаете <cctype> вместо <ctype.h> (т.е. C++-версию заголовочного файла C, которая помещает функции в пространство имён std::), то и в этом случае код будет работать медленно, потому что <cctype> в конечном итоге включает и <bits/os_defines.h>.
Так ли это всё важно? Да нет.
Функция toupper не годится для серьёзной работы с символами разных языков, так что если вам нужно обрабатывать только ASCII-символы, то можете написать собственную более быструю реализацию. Если же требуется серьёзная работа с текстом, то, скорее всего, вы будете использовать UTF-8 и придётся применять какой-нибудь ICU, чтобы поддерживались региональные настройки, либо подождать, пока в C++ не появится поддержка Юникода (ждать, возможно, придётся долго). Так что заявление «clang-format может вызвать 4-кратное падение производительности» годится разве что в качестве кликбейт-заголовка.
Наблюдается ли этот эффект во всех версиях libc? Да, почти во всех, однако и тут всё не так просто.
Показанные выше результаты точно справедливы для gcc 5.5 и glibc 2.23, поскольку именно этими версиями пользовался я, но в новых версиях творится нечто странное (начиная примерно с glibc 2.27). Там включение <algorithm> перед <ctype.h> по-прежнему даёт тот же эффект, но теперь ещё проблемы создаёт и <stdlib.h> [10]: если его включить перед <ctype.h>, производительность также упадёт, чего не наблюдается в более ранних версиях. Очевидно, что в более новых версиях файл <stdlib.h> также содержит определение __NO_CTYPE. По крайней мере, теперь уже не получится свалить вину на сортировку clang-format – здесь она как раз может помочь решить проблему (если в списке подключаемых файлов отсутствуют другие проблемные файлы).
Я разместил отчёт об ошибке в libc [9], так что, наверно, конкретно этот баг починят, но можно не сомневаться, что ошибки, связанные с порядком подключения заголовочных файлов, будут донимать нас и дальше.
У меня на сайте нет системы комментирования, но я работаю над этим (т.е. периодически ною, что трудно сделать комментарии на статическом сайте).
А пока обсудить эту статью можно на сайте Hacker News [10] или lobste.rs [11].
Спасибо пользователю ufo с Hacker News, который указал [12], что необязательно использовать лямбда-функцию, чтобы приспособить std::toupper для использования в std::transform, а также Джонатану Мюллеру, который объяснил [13], что лямбда-функция всё-таки нужна.
Примечание. Эта статья была впервые опубликована на сайте "Performance Matters [16]". Оригинал и перевод статьи размещаются на нашем сайте с разрешения автора.
Автор: Andrey Karpov
Источник [17]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/optimizatsiya-koda/339835
Ссылки в тексте:
[1] toupper: http://man7.org/linux/man-pages/man3/toupper.3.html
[2] можете взять на github: https://github.com/travisdowns/toupper-bench
[3] функцию toupper: https://linux.die.net/man/3/toupper
[4] более современный: https://www.youtube.com/watch?v=2olsGf6JIkU
[5] как показано в этой заметке: https://lemire.me/blog/2019/10/16/benchmarking-is-hard-processors-learn-to-predict-branches/
[6] разных файлах: https://github.com/travisdowns/toupper-bench/blob/256bb8318444faa8411ca6a9b11dcf4396f9ee81/impls-noalgo.cpp
[7] быстрая и медленная: https://godbolt.org/z/DwZBJM
[8] исходный код: https://sourceware.org/git/?p=glibc.git;a=blob;f=ctype/ctype.h;h=d17f727cf0dc2a0f6c62fa50aff799b175dcb426;hb=2a764c6ee848dfe92cb2921ed3b14085f15d9e79#l205
[9] отчёт об ошибке в libc: https://sourceware.org/bugzilla/show_bug.cgi?id=25214
[10] Hacker News: https://news.ycombinator.com/item?id=21579333
[11] lobste.rs: https://lobste.rs/s/tjxzck/clang_format_tanks_performance
[12] указал: https://news.ycombinator.com/item?id=21579483
[13] объяснил: https://twitter.com/foonathan/status/1197051249822195712
[14] эта оптимизация применяется: https://godbolt.org/z/Kb6pc8
[15] ↵: #%D0%92%D0%BE%D0%B7%D0%B2%D1%80%D0%B0%D1%828
[16] Performance Matters: https://travisdowns.github.io/blog/2019/11/19/toupper.html
[17] Источник: https://habr.com/ru/post/480012/?utm_source=habrahabr&utm_medium=rss&utm_campaign=480012
Нажмите здесь для печати.