У коментарях про деякі деталі / передумови для цього було багато (трохи або повністю) неправильних здогадок.
Ви дивитесь на оптимізовану C-систему резервного копіювання glibc. (Для ISA, які не мають рукописної реалізації ASM) . Або стару версію цього коду, яка все ще знаходиться у дереві джерела glibc. https://code.woboq.org/userspace/glibc/string/strlen.c.html - це браузер із кодом на основі поточного дерева glibc git. Мабуть, він все ще використовується кількома основними цілями 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, де вектор-> GP реєструє затримку конвеєра, тому він фактично використовує цей битхак . Але використовує підрахунок нулів, щоб знайти байт в регістрі, як тільки він отримав хіт, і скористається ефективними нерівними доступами AArch64 після перевірки на перехід сторінки.
Також пов'язано: Чому цей код на 6,5 разів повільніше з оптимізаціями? має кілька детальних відомостей про те, що швидко в порівнянні з повільним в x86 ASM для strlen
великого буфера та простої реалізації ASM, що може бути корисним для gcc, щоб знати, як вбудувати. (Деякі версії gcc нерозумно вбудовуються rep scasb
дуже повільно, або 4-байтний одночасний бітхак, як це. Отже, вбудований рецепт GCC потребує оновлення або відключення.)
Asm не має "невизначеної поведінки" у стилі С ; безпечно отримувати доступ до байтів у пам'яті, як тільки ви не хочете, і вирівняне завантаження, що включає будь-які дійсні байти, не може помилитися. Захист пам’яті відбувається із деталізацією вирівнюваної сторінки; Доступ до вирівнювання, який узгоджується, не може перетинати межу сторінки. Чи безпечно читати минулий кінець буфера на одній сторінці на x86 та x64? Це ж міркування стосується машинного коду, який цей хак C отримує компілятори для створення для автономної нелінійної реалізації цієї функції.
Коли компілятор видає код для виклику невідомої не вбудованої функції, він повинен припустити, що функція змінює будь-які / всі глобальні змінні та будь-яку пам'ять, на яку, можливо, має вказівник. тобто все, крім місцевих жителів, у яких не було адреси адреси, повинно синхронізуватися в пам'яті протягом виклику. Це, очевидно, стосується функцій, записаних у ASM, але також і до функцій бібліотеки. Якщо не ввімкнути оптимізацію часу зв’язку, вона застосовується навіть до окремих перекладацьких одиниць (вихідних файлів).
Чому це безпечно як частина glibc, але не інакше.
Найважливішим фактором є те, що це strlen
не може вписатись ні в що інше. Це не безпечно для цього; він містить суворо-псевдонім UB (зчитування char
даних через an unsigned long*
). char*
дозволено Алиас що - небудь інше , але зворотне НЕ вірно .
Це функція бібліотеки для заздалегідь складеної бібліотеки (glibc). Він не буде впорядкований з оптимізацією часу зв’язку в абонентів. Це означає, що він просто повинен компілювати безпечний машинний код для автономної версії strlen
. Він не повинен бути портативним / безпечним.
Бібліотека 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; цикл нерівномірного прологу завжди буде виконувати 0 ітерацій, sizeof(long) = sizeof(char) = 1
тому що кожен вказівник завжди вирівнюється і p & sizeof(long)-1
завжди дорівнює нулю.) Але якщо у вас був набір символів не ASCII, де символів 9 або 12 біт завширшки 0x8080...
- це неправильна картина.
- (можливо)
unsigned long
становить 4 або 8 байт. Або, можливо, це дійсно працювало б для будь-якого розміру unsigned long
до 8, і він використовує assert()
для перевірки цього.
Ці два типи UB неможливі, вони просто непереносимі для деяких реалізацій C. Цей код є (або був) частиною реалізації C на платформах, де він працює, тому це добре.
Наступне припущення - потенційний C UB:
- Вирівняне навантаження, що містить будь-які дійсні байти, не може помилитися і є безпечним, якщо ви ігноруєте байти поза об'єктом, який ви насправді хочете. (Правда в ASM на всіх системах GNU та на всіх звичайних процесорах, оскільки захист пам’яті відбувається з зернистістю вирівнюваних сторінок. Чи безпечно читати минулий кінець буфера в межах однієї сторінки на x86 та x64? Безпечно в C, коли UB не видно під час компіляції. Без вкладок це так і тут. Компілятор не може довести, що читання минулого першого
0
- це UB; це може бути char[]
масив C, що містить, {1,2,0,3}
наприклад)
Цей останній момент - це те, що безпечно читати тут минулий кінець об’єкта С. Це майже безпечно навіть при упорядкуванні з поточними компіляторами, тому що я думаю, що в даний час вони не розглядають те, що передбачає шлях виконання є недосяжним. Але все одно, суворий псевдонім - це вже стоп-шоупер, якщо ви коли-небудь дозволите це інлайн
Тоді у вас виникнуть такі проблеми, як старий небезпечний memcpy
макрос CPP ядра Linux, який використовував кастинг покажчиків unsigned long
( gcc, суворі псевдоніми та історії жахів ).
Це strlen
датується тією епохою, коли ти взагалі можеш відійти від подібних речей ; вона була досить безпечною без застереження "лише тоді, коли не вставляти" перед GCC3.
UB, який видно лише при перегляді меж виклику / повторного виклику, не може нашкодити нам. (наприклад, викликати це char buf[]
замість масиву unsigned long[]
відліків до a const char*
). Після того, як машинний код встановлений в камені, він просто має справу з байтами в пам'яті. Виклик функції, що не вбудовується, повинен припускати, що виклик читає будь-яку / всю пам'ять.
Писати це безпечно, без суворого псевдоніму UB
Атрибут типу GCCmay_alias
надає типу такий же спосіб псевдоніму, що і обмін char*
. (Запропоновано @KonradBorowsk). В даний час заголовки GCC використовують його для таких типів векторів, як SIM86 x86, __m128i
тому ви завжди можете їх безпечно робити _mm_loadu_si128( (__m128i*)foo )
. (Див. "Перевтілення_cast" між апаратним векторним покажчиком та відповідним типом невизначеною поведінкою? Докладніше про те, що це робить, а не означає.)
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 це не небезпечно на практиці.
Для чого потрібне оптимізоване джерело С: потрібні компілятори недостатньо хороші
Оптимізований вручну ASM може бути ще кращим, коли ви хочете кожен останній спад ефективності для широко використовуваної стандартної функції бібліотеки. Особливо для чогось подібного memcpy
, але також strlen
. У цьому випадку було б не набагато простіше використовувати C з внутрішніми характеристиками x86, щоб скористатися SSE2.
Але тут ми просто говоримо про наївну та бітхак версію C без будь-яких особливостей ISA.
(Я думаю, що ми можемо сприймати це як дану, яка strlen
досить широко використовується, щоб зробити її максимально швидкою. Важливим є питання, чи зможемо ми отримати простий машинний код з більш простого джерела. Ні, ми не можемо.)
Поточні GCC та кланг не здатні до автоматичної векторизації циклів, коли кількість ітерацій не відома перед першою ітерацією . (наприклад, потрібно перевірити, чи буде цикл виконувати принаймні 16 ітерацій, перш ніж запустити першу ітерацію.) Наприклад, можлива автоматична векторизація memcpy (буфер явної довжини), але не strcpy або strlen (рядок неявної довжини), заданий поточний компілятори.
Це включає петлі пошуку або будь-який інший цикл із залежними від даних if()break
, а також лічильник.
ICC (компілятор Intel для x86) може автоматично векторизувати певні петлі пошуку, але все ще робить лише наївну байт-базу одночасно для простого / наївного C, strlen
наприклад, як libc OpenBSD. ( Годбольт ). (З відповіді @ Песке ).
Ручний оптимізований libc strlen
необхідний для роботи з поточними компіляторами . Перехід 1 байт одночасно (з розгортанням, можливо, 2 байтів на цикл на широких суперскалярних процесорах) є пафосним, коли основна пам'ять може не відставати від близько 8 байтів за цикл, а кеш L1d може доставляти 16 до 64 за цикл. (2x 32-байтові навантаження за цикл на сучасних центральних процесорах x86 від Haswell та Ryzen. Не рахуючи AVX512, який може зменшити тактову частоту лише для використання 512-бітових векторів; саме тому glibc, мабуть, не поспішає додавати версію AVX512 . Хоча з 256-бітовими векторами, AVX512VL + BW маскується порівняно в маску і / ktest
або kortest
може зробити strlen
більш сприятливим для гіпертонінгу, зменшивши його Uops / ітерацію.)
Я включаю тут не-x86, це "16 байт". наприклад, більшість процесорів AArch64 можуть зробити принаймні те, я думаю, і деякі, звичайно, більше. А деякі мають достатню пропускну спроможність, strlen
щоб не відставати від пропускної здатності навантаження.
Звичайно, програми, які працюють з великими рядками, зазвичай повинні відслідковувати довжини, щоб уникнути необхідності повторювати пошук C рядків неявної довжини дуже часто. Але ефективність від короткої до середньої довжини все-таки виграє від рукописних реалізацій, і я впевнений, що деякі програми в кінцевому підсумку використовують струни на рядках середньої довжини.