Там было много (немного или полностью) неправильных предположений в комментариях о некоторых деталях / предыстории для этого.
Вы смотрите на оптимизированную реализацию glibc, оптимизированную для резервного копирования. (Для ISA, у которых нет рукописной реализации asm) . Или старая версия этого кода, которая все еще находится в исходном дереве glibc. https://code.woboq.org/userspace/glibc/string/strlen.c.html - это браузер кода, основанный на текущем git-дереве glibc. По-видимому, он все еще используется несколькими основными целями glibc, включая MIPS. (Спасибо, @zwol).
На популярных ISA, таких как x86 и ARM, glibc использует рукописный asm
Таким образом, стимул изменить что-либо в этом коде ниже, чем вы думаете.
Этот битхак-код ( https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord ) не тот, который на самом деле работает на вашем сервере / настольном компьютере / ноутбуке / смартфоне. Это лучше, чем простой байтовый цикл, но даже этот битхак довольно плох по сравнению с эффективным asm для современных процессоров (особенно x86, где AVX2 SIMD позволяет проверять 32 байта с помощью пары инструкций, позволяя от 32 до 64 байтов в такт Цикл в основном цикле, если данные горячие в кеше L1d на современных процессорах с 2 / тактовой векторной нагрузкой и пропускной способностью ALU, т.е. для строк среднего размера, где накладные расходы при запуске не доминируют.)
glibc использует приемы динамического связывания для strlen
определения оптимальной версии для вашего процессора, поэтому даже в x86 есть версия SSE2 (16-байтовые векторы, базовый для x86-64) и версия AVX2 (32-байтовые векторы).
x86 имеет эффективную передачу данных между векторными и универсальными регистрами, что делает его уникальным (?) подходящим для использования SIMD для ускорения функций в строках неявной длины, где управление циклом зависит от данных. pcmpeqb
/ pmovmskb
позволяет тестировать 16 отдельных байтов одновременно.
glibc имеет версию AArch64, аналогичную версии с использованием AdvSIMD , и версию для процессоров AArch64, где vector-> GP регистрирует останов конвейера, поэтому он действительно использует этот битхак . Но использует счетчики, ведущие к нулям, чтобы найти байт внутри регистра, как только он получит попадание, и использует эффективный доступ без выравнивания AArch64 после проверки на пересечение страниц.
Также связано: почему этот код в 6.5 раз медленнее с включенной оптимизацией? есть некоторые подробности о том, что является быстрым или медленным в x86 asm для strlen
с большим буфером и простой реализацией asm, что может быть полезно для gcc, чтобы знать, как встроить. (Некоторые версии gcc неразумно встроены, rep scasb
что очень медленно, или битовый хакер по 4 байта за раз, как этот. Поэтому рецепт GCC inline-strlen нуждается в обновлении или отключении.)
У Asm нет "неопределенного поведения" в стиле C ; доступ к байтам в памяти безопасен, как вам угодно, и выровненная загрузка, включающая в себя любые допустимые байты, не может дать сбой. Защита памяти происходит с гранулярностью выровненных страниц; Выровненный доступ, более узкий, чем тот, который не может пересечь границу страницы. Безопасно ли читать за пределами буфера на одной и той же странице на x86 и x64? Те же рассуждения применимы к машинному коду, который этот хакер C заставляет компиляторы создавать для автономной не встроенной реализации этой функции.
Когда компилятор генерирует код для вызова неизвестной не встроенной функции, он должен предполагать, что функция изменяет любые / все глобальные переменные и любую память, на которую может иметь указатель. то есть все, кроме местных жителей, у которых не было экранирования адреса, должно быть синхронизировано в памяти через вызов. Это относится к функциям, написанным в asm, очевидно, но также и к библиотечным функциям. Если вы не включаете оптимизацию во время компоновки, она применяется даже к отдельным единицам перевода (исходным файлам).
Почему это безопасно как часть glibc, но никак иначе.
Наиболее важным фактором является то, что это strlen
не может влиять ни на что другое. Это не безопасно для этого; он содержит строгий псевдоним UB (чтение char
данных через unsigned long*
). char*
разрешено Алиас что - либо другое , но обратное не верно .
Это функция библиотеки для заранее скомпилированной библиотеки (glibc). Это не будет встроено с оптимизацией времени соединения к вызывающим. Это означает, что он просто должен скомпилировать в безопасный машинный код для автономной версии strlen
. Это не должно быть портативным / безопасным C.
Библиотека GNU C должна компилироваться только с GCC. Очевидно, что не поддерживается компиляция с использованием clang или ICC, даже если они поддерживают расширения GNU. GCC - опережающий компилятор, который превращает исходный файл C в объектный файл машинного кода. Не интерпретатор, поэтому, если он не встроен во время компиляции, байты в памяти - это просто байты в памяти. т.е. UB со строгим псевдонимом не опасен, когда доступ с разными типами происходит в разных функциях, которые не связаны друг с другом.
Помните , что strlen
поведение «S определяется по стандарту ISO C. Это имя функции определенно является частью реализации. Компиляторы, такие как GCC, даже обрабатывают имя как встроенную функцию, если вы не используете ее -fno-builtin-strlen
, поэтому она strlen("foo")
может быть константой времени компиляции 3
. Определение в библиотеке используется только в том случае, если gcc решает на самом деле передать ему вызов, а не вставлять свой собственный рецепт или что-то в этом роде.
Когда UB не виден компилятору во время компиляции, вы получаете нормальный машинный код. Машинный код должен работать в случае не-UB, и даже если вы хотели , чтобы, нет никакого способа для ASM , чтобы обнаружить , какие типы вызывающие клали данные в указываемом в память.
Glibc скомпилирован в автономную статическую или динамическую библиотеку, которая не может быть встроена в оптимизацию во время соединения. Сценарии сборки glibc не создают «жирных» статических библиотек, содержащих машинный код + внутреннее представление gcc GIMPLE для оптимизации во время компоновки при встраивании в программу. (т.е. libc.a
не будет участвовать в -flto
оптимизации времени соединения с основной программой.) Построение glibc таким образом было бы потенциально небезопасно для целей, которые фактически используют это.c
.
Фактически, как комментирует @zwol, LTO не может использоваться при сборке самого glibc из-за «хрупкого» кода, подобного этому, который может сломаться, если будет возможно встраивание между исходными файлами glibc. (Есть некоторые внутренние применения strlen
, например, возможно, как часть printf
реализации)
Это strlen
делает некоторые предположения:
CHAR_BIT
кратно 8 . Правда на всех системах GNU. POSIX 2001 даже гарантирует CHAR_BIT == 8
. (Это выглядит безопасным для систем с CHAR_BIT= 16
или 32
, как некоторые DSP; цикл unaligned-prologue будет всегда выполнять 0 итераций, если, sizeof(long) = sizeof(char) = 1
потому что каждый указатель всегда выровнен и p & sizeof(long)-1
всегда равен нулю.) Но если у вас был набор символов не-ASCII, где chars равны 9 или 12 бит в ширину, 0x8080...
это неправильный шаблон.
- (возможно)
unsigned long
составляет 4 или 8 байтов. Или, может быть, он будет работать для любого размера unsigned long
до 8, и он использует assert()
для проверки этого.
Эти два невозможны UB, они просто не переносимы для некоторых реализаций Си. Этот код является (или был) частью реализации C на платформах, где он работает, так что это нормально.
Следующее предположение является потенциальным C UB:
- Выровненная загрузка, которая содержит любые допустимые байты, не может быть ошибочной и является безопасной, если вы игнорируете байты вне объекта, который вы на самом деле хотите. (Верно в asm на всех системах GNU и на всех обычных процессорах, потому что защита памяти происходит с гранулярностью выровненных страниц. Безопасно ли читать после конца буфера на той же странице на x86 и x64? Безопасно на C, когда UB не виден во время компиляции. Без встраивания, это имеет место здесь. Компилятор не может доказать, что чтение после первого
0
- UB; это может быть char[]
массив C, содержащий, {1,2,0,3}
например)
Этот последний момент - то, что делает безопасным чтение после конца объекта C здесь. Это довольно безопасно, даже если встраивать с текущими компиляторами, потому что я думаю, что в настоящее время они не считают, что путь исполнения недоступен. Но, в любом случае, строгий псевдоним уже стоит на пороге, если вы когда-нибудь позволите этому встроиться.
Тогда у вас будут проблемы, такие как старый небезопасный memcpy
макрос CPP ядра Linux, который использует приведение указателей к unsigned long
( gcc, строгое псевдонимы и ужасные истории ).
Это strlen
восходит к эпохе, когда вы могли бы сойти с рук в общем такие вещи ; Раньше до GCC3 он был довольно безопасным без предостережения «только когда не встраивался».
UB, который виден только при взгляде через границы вызова / возврата, не может повредить нам. (например, вызывая это char buf[]
вместо массива unsigned long[]
приведения к a const char*
). Как только машинный код установлен в камне, он просто работает с байтами в памяти. При вызове не встроенной функции необходимо предположить, что вызываемый объект считывает любую / всю память.
Написание этого безопасно, без строго псевдонимов UB
Атрибут типа НКУmay_alias
дает тип такой же псевдоним, ничего лечения как char*
. (Предложено @KonradBorowsk). В настоящее время заголовки GCC используют его для векторных типов x86 SIMD, __m128i
так что вы всегда можете это сделать безопасно _mm_loadu_si128( (__m128i*)foo )
. (См. 'Reinterpret_cast`ing между аппаратным указателем вектора и соответствующим типом неопределенного поведения? Для получения дополнительной информации о том, что это делает и не означает.)
strlen(const char *char_ptr)
{
typedef unsigned long __attribute__((may_alias)) aliasing_ulong;
aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
for (;;) {
unsigned long ulong = *longword_ptr++; // can safely alias anything
...
}
}
Вы также можете использовать aligned(1)
для выражения типа с alignof(T) = 1
.
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;
Портативный способ выразить нагрузку на псевдонимы в ISO - это тоmemcpy
, что современные компиляторы знают, как встроить в качестве одной инструкции загрузки. например
unsigned long longword;
memcpy(&longword, char_ptr, sizeof(longword));
char_ptr += sizeof(longword);
Это также работает для не выровненных нагрузок, потому что memcpy
работает как-будто char
-на-время доступа. Но на практике современные компиляторы это прекрасно понимают memcpy
.
Опасность здесь заключается в том, что если GCC не знает наверняка, что char_ptr
выровнено по словам, оно не встроит его на некоторых платформах, которые могут не поддерживать невыровненные загрузки в asm. например, MIPS до MIPS64r6 или более ранняя версия ARM. Если бы вы получили реальный вызов функции, чтобы memcpy
просто загрузить слово (и оставить его в другой памяти), это было бы катастрофой. GCC иногда может видеть, когда код выравнивает указатель. Или после цикла char-at-a-time, который достигает удлиненной границы, вы можете использовать
p = __builtin_assume_aligned(p, sizeof(unsigned long));
Это не исключает возможности UB для чтения за объектом, но с текущим GCC это не опасно на практике.
Почему необходим оптимизированный вручную C-источник: современные компиляторы недостаточно хороши
Оптимизированный вручную ассм может быть еще лучше, если вы хотите, чтобы каждая последняя капля производительности для широко используемой стандартной функции библиотеки. Особенно за что-то подобное memcpy
, но также strlen
. В этом случае было бы намного проще использовать C с внутренними компонентами x86, чтобы использовать преимущества SSE2.
Но здесь мы просто говорим о версии C наивной против bithack без каких-либо специфичных для ISA функций.
(Я думаю, что мы можем принять его как strlen
достаточно широко используемое, поэтому важно, чтобы оно работало максимально быстро. Поэтому возникает вопрос, можем ли мы получить эффективный машинный код из более простого источника. Нет, мы не можем.)
Текущие GCC и clang не способны автоматически векторизовать циклы, где число итераций не известно до первой итерации . (Например, должна быть возможность проверить, будет ли цикл выполняться по крайней мере 16 итераций перед выполнением первой итерации.) Например, возможна автовекторизация memcpy (буфер явной длины), но не strcpy или strlen (строка неявной длины), учитывая текущий компиляторы.
Это включает в себя поисковые циклы или любой другой цикл с зависимым от данных, if()break
а также счетчиком.
ICC (компилятор Intel для x86) может автоматически векторизовать некоторые поисковые циклы, но все еще создает наивный асимметричный асимметричный ассемблер для простого / наивного C, strlen
такого как libc в OpenBSD. ( Godbolt ). (Из ответа @ Песке ).
Оптимизированный вручную libc strlen
необходим для производительности с текущими компиляторами . Переход по 1 байту за раз (при развертывании, возможно, 2 байта за цикл на широких суперскалярных процессорах) жалок, когда основная память может поддерживать около 8 байтов за цикл, а кэш-память L1d может доставлять от 16 до 64 за цикл. (2x 32-байтовые загрузки за цикл на современных основных процессорах x86 начиная с Haswell и Ryzen. Не считая AVX512, который может снизить тактовые частоты только для использования 512-битных векторов; именно поэтому glibc, вероятно, не спешит добавлять версию AVX512 Хотя AVX512VL + BW с 256-битными векторами маскируются, сравниваются с маской и / ktest
или kortest
могут сделать strlen
более дружественным к гиперпоточности, уменьшив количество операций / итераций.)
Я включаю не x86 здесь, это "16 байт". например, большинство процессоров AArch64, по-моему, могут сделать это, а некоторые, безусловно, больше. А некоторые имеют достаточную пропускную способность, strlen
чтобы не отставать от этой полосы пропускания нагрузки.
Конечно, программы, работающие с большими строками, обычно должны отслеживать длины, чтобы избежать необходимости повторного поиска длины строк C неявной длины. Но производительность от короткой до средней длины все еще выигрывает от рукописных реализаций, и я уверен, что некоторые программы в конечном итоге используют strlen для строк средней длины.