Чому струн glibc повинен бути таким складним, щоб швидко працювати?


286

Я переглядав strlenкод тут і мені було цікаво, чи дійсно потрібні оптимізації, використовувані в коді? Наприклад, чому б щось подібне не було настільки ж добре чи краще?

unsigned long strlen(char s[]) {
    unsigned long i;
    for (i = 0; s[i] != '\0'; i++)
        continue;
    return i;
}

Чи не простіший код краще та / або простіше оптимізувати компілятор?

Код strlenна сторінці за посиланням виглядає так:

/* Copyright (C) 1991, 1993, 1997, 2000, 2003 Free Software Foundation, Inc.
   This file is part of the GNU C Library.
   Written by Torbjorn Granlund (tege@sics.se),
   with help from Dan Sahlin (dan@sics.se);
   commentary by Jim Blandy (jimb@ai.mit.edu).

   The GNU C Library is free software; you can redistribute it and/or
   modify it under the terms of the GNU Lesser General Public
   License as published by the Free Software Foundation; either
   version 2.1 of the License, or (at your option) any later version.

   The GNU C Library is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   Lesser General Public License for more details.

   You should have received a copy of the GNU Lesser General Public
   License along with the GNU C Library; if not, write to the Free
   Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
   02111-1307 USA.  */

#include <string.h>
#include <stdlib.h>

#undef strlen

/* Return the length of the null-terminated string STR.  Scan for
   the null terminator quickly by testing four bytes at a time.  */
size_t
strlen (str)
     const char *str;
{
  const char *char_ptr;
  const unsigned long int *longword_ptr;
  unsigned long int longword, magic_bits, himagic, lomagic;

  /* Handle the first few characters by reading one character at a time.
     Do this until CHAR_PTR is aligned on a longword boundary.  */
  for (char_ptr = str; ((unsigned long int) char_ptr
            & (sizeof (longword) - 1)) != 0;
       ++char_ptr)
    if (*char_ptr == '\0')
      return char_ptr - str;

  /* All these elucidatory comments refer to 4-byte longwords,
     but the theory applies equally well to 8-byte longwords.  */

  longword_ptr = (unsigned long int *) char_ptr;

  /* Bits 31, 24, 16, and 8 of this number are zero.  Call these bits
     the "holes."  Note that there is a hole just to the left of
     each byte, with an extra at the end:

     bits:  01111110 11111110 11111110 11111111
     bytes: AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD

     The 1-bits make sure that carries propagate to the next 0-bit.
     The 0-bits provide holes for carries to fall into.  */
  magic_bits = 0x7efefeffL;
  himagic = 0x80808080L;
  lomagic = 0x01010101L;
  if (sizeof (longword) > 4)
    {
      /* 64-bit version of the magic.  */
      /* Do the shift in two steps to avoid a warning if long has 32 bits.  */
      magic_bits = ((0x7efefefeL << 16) << 16) | 0xfefefeffL;
      himagic = ((himagic << 16) << 16) | himagic;
      lomagic = ((lomagic << 16) << 16) | lomagic;
    }
  if (sizeof (longword) > 8)
    abort ();

  /* Instead of the traditional loop which tests each character,
     we will test a longword at a time.  The tricky part is testing
     if *any of the four* bytes in the longword in question are zero.  */
  for (;;)
    {
      /* We tentatively exit the loop if adding MAGIC_BITS to
     LONGWORD fails to change any of the hole bits of LONGWORD.

     1) Is this safe?  Will it catch all the zero bytes?
     Suppose there is a byte with all zeros.  Any carry bits
     propagating from its left will fall into the hole at its
     least significant bit and stop.  Since there will be no
     carry from its most significant bit, the LSB of the
     byte to the left will be unchanged, and the zero will be
     detected.

     2) Is this worthwhile?  Will it ignore everything except
     zero bytes?  Suppose every byte of LONGWORD has a bit set
     somewhere.  There will be a carry into bit 8.  If bit 8
     is set, this will carry into bit 16.  If bit 8 is clear,
     one of bits 9-15 must be set, so there will be a carry
     into bit 16.  Similarly, there will be a carry into bit
     24.  If one of bits 24-30 is set, there will be a carry
     into bit 31, so all of the hole bits will be changed.

     The one misfire occurs when bits 24-30 are clear and bit
     31 is set; in this case, the hole at bit 31 is not
     changed.  If we had access to the processor carry flag,
     we could close this loophole by putting the fourth hole
     at bit 32!

     So it ignores everything except 128's, when they're aligned
     properly.  */

      longword = *longword_ptr++;

      if (
#if 0
      /* Add MAGIC_BITS to LONGWORD.  */
      (((longword + magic_bits)

        /* Set those bits that were unchanged by the addition.  */
        ^ ~longword)

       /* Look at only the hole bits.  If any of the hole bits
          are unchanged, most likely one of the bytes was a
          zero.  */
       & ~magic_bits)
#else
      ((longword - lomagic) & himagic)
#endif
      != 0)
    {
      /* Which of the bytes was the zero?  If none of them were, it was
         a misfire; continue the search.  */

      const char *cp = (const char *) (longword_ptr - 1);

      if (cp[0] == 0)
        return cp - str;
      if (cp[1] == 0)
        return cp - str + 1;
      if (cp[2] == 0)
        return cp - str + 2;
      if (cp[3] == 0)
        return cp - str + 3;
      if (sizeof (longword) > 4)
        {
          if (cp[4] == 0)
        return cp - str + 4;
          if (cp[5] == 0)
        return cp - str + 5;
          if (cp[6] == 0)
        return cp - str + 6;
          if (cp[7] == 0)
        return cp - str + 7;
        }
    }
    }
}
libc_hidden_builtin_def (strlen)

Чому ця версія працює швидко?

Хіба це не робить багато зайвої роботи?


2
Коментарі не для розширеного обговорення; ця розмова була переміщена до чату .
Samuel Liew

18
Для подальшої довідки офіційний сховище джерела для GNU libc знаходиться за адресою < sourceware.org/git/?p=glibc.git >. < sourceware.org/git/?p=glibc.git;a=blob;f=string/… > дійсно показує код, подібний до вищевказаного; однак, вручну написана реалізація мови збірки з sysdepsкаталогу буде замість цього застосовуватися для більшості підтримуваних архітектур glibc (найчастіше використовується архітектура, яка не замінює MIPS).
zwol

9
Голосування за те, щоб закрити це як насамперед на основі думки; "Чи справді ххх потрібні в ххх?" є суб'єктивним щодо думки людей.
СС Енн

2
@ JL2210: Добре, зафіксував заголовок, щоб зафіксувати дух питання в заголовку, який не здається, що це цікаво, чи потрібна продуктивність, просто для чого нам потрібні ці оптимізації для отримання продуктивності.
Пітер Кордес

9
@ JL2210 FWIW, оригінальна назва була "Чому в C [sic!] Сторленна така складна", і вона закрилася як "занадто широка", потім знову відкрита, потім закрита як "в основному на основі думки". Я спробував це виправити (потрапляючи в перехресний вогонь "ви порушили моє запитання!" Та "Ви тим часом, ви, хлопці, зловживаєте вашими повноваженнями щодо редагування!", Але проблема IMVHO збрехала (і досі лежить) в основній передумові питання, що було проблематично ("цей код для мене занадто складний, щоб зрозуміти", він не підходить для запитань і запитань - IMO - це запит для навчання, а не для відповіді). Я не торкаюся його знову 60-футовим стовпом :)

Відповіді:


233

Вам не потрібно і ніколи не слід писати такий код - особливо якщо ви не компілятор C / стандартний постачальник бібліотеки. Це код, який використовується для реалізації strlenдуже сумнівних швидкості хак і припущень (які не перевіряються твердженнями або згадуються в коментарях):

  • unsigned long становить 4 або 8 байт
  • байт - 8 біт
  • покажчик може бути переданий на unsigned long longта неuintptr_t
  • можна вирівняти вказівник, просто перевіривши, що 2 або 3 біти нижчого порядку дорівнюють нулю
  • можна отримати доступ до рядка як unsigned longs
  • можна прочитати повз кінець масиву без жодних негативних наслідків.

Більше того, хороший компілятор міг навіть замінити код, написаний як

size_t stupid_strlen(const char s[]) {
    size_t i;
    for (i=0; s[i] != '\0'; i++)
        ;
    return i;
}

(зауважте, що він повинен бути типом, сумісним із size_t) з вбудованою версією вбудованого компілятора strlenабо векторним кодом; але компілятор навряд чи зможе оптимізувати складну версію.


strlenФункція описується С11 7.24.6.3 , як:

Опис

  1. strlenФункція обчислює довжину рядка , на яку вказує с.

Повертається

  1. strlenФункція повертає кількість символів , які передують завершальні нульовий символ.

Тепер, якщо рядок, на який вказують, sзнаходився в масиві символів достатньо довго, щоб містити рядок і закінчуючий NUL, поведінка буде невизначеною, якщо ми отримаємо доступ до рядка повз нульового термінатора, наприклад у

char *str = "hello world";  // or
char array[] = "hello world";

Тож справді єдиним способом у повному портативному / сумісному стандартах C правильно це здійснити - це так, як написано у вашому запитанні , за винятком тривіальних перетворень - ви можете зробити вигляд, що швидше, розкрутивши цикл тощо, але це все одно потрібно зробити по одному байту .

(Як зазначають коментатори, коли сувора переносимість занадто велика навантаження, користуватися розумними або відомими безпечними припущеннями - це не завжди погано. Особливо в коді, який є частиною конкретної реалізації С. Але ви повинні розуміти правила, перш ніж знати, як / коли їх можна зігнути.)


Зв'язана strlenреалізація спочатку перевіряє байти окремо, поки покажчик не вказує на природну межу вирівнювання 4 або 8 байтів unsigned long. Стандарт C говорить, що доступ до вказівника, який не вирівняний належним чином, має невизначену поведінку , тому це абсолютно необхідно зробити, щоб наступний брудний трюк став ще бруднішим. (На практиці в деяких архітектурах процесора, окрім x86, виникла несогласована навантаження слова або подвійне слово. C - це не портативна мова складання, але цей код використовує її таким чином). Це також дає змогу читати повз кінець об’єкта без ризику виходу з ладу в реалізаціях, де захист пам’яті працює у вирівняних блоках (наприклад, сторінки віртуальної пам’яті 4kiB).

Зараз йде брудна частина: код перерви Обіцянки і читає 4 або 8 8-бітові байти в той час (а long int), і використовує трохи трюк з підписаним того , щоб швидко з'ясувати, чи є якісь - або нульові байти в тих 4 або 8 байти - для цього використовується спеціально створений номер, який би спричинив зміну бітів для зміни бітів, які потрапляють у бітну маску. По суті, це було б потім з'ясувати, якщо будь-який з 4 або 8 байтів у масці є нулями, нібито швидшими, ніж прокручування кожного з цих байтів. Нарешті, є кінець в кінці, щоб визначити, який байт був першим нулем, якщо такий є, і повернути результат.

Найбільша проблема полягає в тому, що у sizeof (unsigned long) - 1випадках, коли sizeof (unsigned long)випадки випадків прочитуються в кінці рядка - лише якщо нульовий байт знаходиться в останньому байті доступу (тобто у little-endian найзначніший, а в big-endian найменш значущий) , це НЕ доступ до масиву поза межами!


Код, незважаючи на те, що використовується для реалізації strlenв стандартній бібліотеці С, є неправильним кодом. У ньому є кілька визначених реалізацією та невизначених аспектів, і він не повинен використовуватися ніде замість наданої системи strlen- я перейменував цю функцію на the_strlenтут і додав наступне main:

int main(void) {
    char buf[12];
    printf("%zu\n", the_strlen(fgets(buf, 12, stdin)));
}

Буфер обережно розміряється так, щоб він міг точно містити hello worldрядок і термінатор. Однак на моєму 64-бітному процесорі unsigned longце 8 байт, тому доступ до останньої частини перевищив би цей буфер.

Якщо я компілювати з -fsanitize=undefinedі -fsanitize=addressі запустити отриману програму, я отримую:

% ./a.out
hello world
=================================================================
==8355==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffffe63a3f8 at pc 0x55fbec46ab6c bp 0x7ffffe63a350 sp 0x7ffffe63a340
READ of size 8 at 0x7ffffe63a3f8 thread T0
    #0 0x55fbec46ab6b in the_strlen (.../a.out+0x1b6b)
    #1 0x55fbec46b139 in main (.../a.out+0x2139)
    #2 0x7f4f0848fb96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
    #3 0x55fbec46a949 in _start (.../a.out+0x1949)

Address 0x7ffffe63a3f8 is located in stack of thread T0 at offset 40 in frame
    #0 0x55fbec46b07c in main (.../a.out+0x207c)

  This frame has 1 object(s):
    [32, 44) 'buf' <== Memory access at offset 40 partially overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism or swapcontext
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (.../a.out+0x1b6b) in the_strlen
Shadow bytes around the buggy address:
  0x10007fcbf420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fcbf470: 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00[04]
  0x10007fcbf480: f2 f2 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==8355==ABORTING

тобто трапилися погані речі.


120
Re: "дуже сумнівні швидкісні хаки та припущення" - тобто дуже сумнівні в портативному коді . Стандартна бібліотека написана для певної компіляції / апаратної комбінації із знанням фактичної поведінки речей, які мовне визначення залишає як невизначене. Так, більшість людей не повинні писати такий код, але в контексті впровадження стандартної бібліотеки непереносний сам по собі не є поганим.
Піт Бекер

4
Погодьтеся, ніколи не пишіть подібні речі самі. Або майже ніколи. Передчасна оптимізація - джерело всього зла. (У цьому випадку це насправді може бути мотивованим). Якщо ви в кінцевому підсумку робите багато викликів strlen () по одній і тій самій довгій строці, можливо, ваша програма може бути написана інакше. Ви, наприклад, мігте зберігати довжину stringle у змінній вже тоді, коли створюється рядок, і зовсім не потрібно викликати strlen ().
ghellquist

65
@ghellquist: Оптимізація часто використовуваного бібліотечного дзвінка навряд чи "передчасна оптимізація".
jamesqf

7
@Antti Haapala: Точно чому ви думаєте, що strlen повинен бути O (1)? Ми маємо тут декілька реалізацій, усі з яких O (n), але з різними постійними множниками. Ви можете не вважати, що це має значення, але для деяких із нас реалізація алгоритму O (n), який виконує свою роботу в мікросекундах, набагато краще, ніж той, який займає секунди, а то й мілісекунди, оскільки його можна назвати в кілька мільярдів разів за хід роботи.
jamesqf

8
@PeteBecker: Мало того, що в контексті стандартних бібліотек (не так вже й у цьому випадку) написання коду, що не переноситься, може бути нормою, оскільки метою стандартної бібліотеки є надання стандартного інтерфейсу для реалізації конкретних речей.
ПлазмаHH

148

У коментарях про деякі деталі / передумови для цього було багато (трохи або повністю) неправильних здогадок.

Ви дивитесь на оптимізовану 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 рядків неявної довжини дуже часто. Але ефективність від короткої до середньої довжини все-таки виграє від рукописних реалізацій, і я впевнений, що деякі програми в кінцевому підсумку використовують струни на рядках середньої довжини.


12
Кілька зауважень: (1) Наразі неможливо скомпілювати glibc з будь-яким компілятором, окрім GCC. (2) Наразі неможливо скомпілювати сам glibc з увімкненими оптимізаціями часу зв’язку, оскільки саме такі випадки випадків, коли компілятор побачить UB, якщо вбудоване дозволено. (3) CHAR_BIT == 8- вимога POSIX (станом на -2001 р.; Див. Тут ). (4) Реалізація резервної системи C strlenвикористовується для деяких підтримуваних процесорів, я вважаю, що найбільш поширеним є MIPS.
zwol

1
Цікаво, що строго-псевдонімічний UB можна було виправити, використовуючи __attribute__((__may_alias__))атрибут (це не портативний, але це має бути добре для glibc).
Конрад Боровський

1
@SebastianRedl: Ви можете читати / писати будь-який об’єкт через a char*, але це все ще UB для читання / запису char об'єкта (наприклад, частини a char[]) через a long*. Суворе правило згладжування та покажчики "char *"
Пітер Кордес,

1
Стандарти C і C ++ говорять, що їх CHAR_BITповинно бути принаймні 8 ( qv Додаток E до C11), тож принаймні 7-бітний charне є тим, про що юрист з мови повинен турбуватися. Це мотивовано вимогою: "Для лінійних літералів UTF-8 елементи масиву мають тип charі ініціалізуються символами багатобайтової послідовності символів, кодованими в UTF-8."
Девіслор

2
Здається, цей аналіз є гарною основою для запропонувати патч, який зробить код більш надійним в умовах оптимізацій, які наразі відключені, окрім отримання дивовижної відповіді.
Deduplicator

61

Це пояснюється в коментарях до файлу, який ви пов’язали:

 27 /* Return the length of the null-terminated string STR.  Scan for
 28    the null terminator quickly by testing four bytes at a time.  */

і:

 73   /* Instead of the traditional loop which tests each character,
 74      we will test a longword at a time.  The tricky part is testing
 75      if *any of the four* bytes in the longword in question are zero.  */

В C можна детально міркувати про ефективність.

Менш ефективно перебирати окремі символи, які шукають нуль, ніж тестувати більше одного байта одночасно, як це робить цей код.

Додаткова складність полягає в тому, що потрібно забезпечити вирівнювання рядка, що перевіряється, у потрібному місці, щоб почати тестувати більше одного байта за раз (уздовж межі довгого слова, як описано в коментарях), і від необхідності переконатися, що припущення про розміри типів даних не порушуються при використанні коду.

У більшості (але не у всіх) сучасних розробках програмного забезпечення ця увага до деталізації ефективності не є необхідною або не вартує витрат на додаткову складність коду.

Одне місце, де має сенс звертати увагу на ефективність, як це є у стандартних бібліотеках, як-от приклад, який ви пов’язали.


Якщо ви хочете прочитати більше про межі слів, перегляньте це питання та цю чудову сторінку з вікіпедії


39

На додаток до чудових відповідей тут, я хочу зазначити, що код, пов'язаний у питанні, призначений для реалізації GNU strlen.

Реалізація OpenBSDstrlen дуже схожа на код, запропонований у питанні. Складність реалізації визначається автором.

...
#include <string.h>

size_t
strlen(const char *str)
{
    const char *s;

    for (s = str; *s; ++s)
        ;
    return (s - str);
}

DEF_STRONG(strlen);

EDIT : Код OpenBSD, з яким я пов’язував вище, виглядає як резервна реалізація для ISA, які не мають власної реалізації ASM. Існують різні реалізації strlenзалежно від архітектури. Наприклад, кодом для amd64strlen є asm. Аналогічно коментарям / відповідям PeterCordes , що вказують на те, що реалізація GNU, що не підпадає назад, також є asm.


5
Це дуже добре ілюструє, що різні значення оптимізовані в інструментах OpenBSD проти GNU.
Джейсон

11
Це портативна реалізація резервної системи glibc . Усі основні ISA мають вручну написані реалізації ASM в glibc, використовуючи SIMD, коли це допомагає (наприклад, на x86). Дивіться code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/… та code.woboq.org/userspace/glibc/sysdeps/aarch64/multiarch/…
Пітер Кордес

4
Навіть у версії OpenBSD є недолік, якого уникає оригінал! Поведінка s - strне визначена, якщо результат не представлений в ptrdiff_t.
Антті

1
@AnttiHaapala: У GNU C максимальний розмір об'єкта дорівнює PTRDIFF_MAX. Але все ж можливо mmapбільше пам’яті, ніж у Linux, щонайменше (наприклад, в 32-бітному процесі під ядром x86-64 я міг скласти близько 2,7 ГБ суміжного, перш ніж почати отримувати збої). IDK про OpenBSD; ядро могло б зробити неможливим досягти цього returnбез segfaulting або зупинки в межах розміру. Але так, ви можете подумати, що захисне кодування, що уникає теоретичного C UB, було б те, що OpenBSD хотів би зробити. Навіть незважаючи на те, що strlenвбудовані, а справжні компілятори просто скомпілюють його до віднімання.
Пітер Кордес

2
@PeterCordes точно. Те саме в OpenBSD, наприклад, збірка i386: cvsweb.openbsd.org/cgi-bin/cvsweb/src/lib/libc/arch/i386/string/…
dchest

34

Коротше кажучи, це оптимізація продуктивності, яку може зробити стандартна бібліотека, знаючи, з яким компілятором вона компілюється - ви не повинні писати такий код, якщо ви не пишете стандартну бібліотеку і може залежати від конкретного компілятора. Зокрема, це обробка одночасно кількості байтів - 4 на 32-бітних платформах, 8 на 64-бітних платформах. Це означає, що це може бути в 4 або 8 разів швидше, ніж наївна байт-ітерація.

Щоб пояснити, як це працює, розгляньте наступне зображення. Припустимо тут 32-бітну платформу (вирівнювання 4 байти).

Скажімо, літера "Н" "Привіт, світ!" рядок був наданий як аргумент для strlen. Оскільки центральному процесору подобається, щоб у пам'яті були вирівняні речі (в ідеалі, address % sizeof(size_t) == 0), байти перед вирівнюванням обробляються байт-байтом, використовуючи повільний метод.

Потім, для кожного фрагмента розмірів вирівнювання, обчислюючи, (longbits - 0x01010101) & 0x80808080 != 0він перевіряє, чи будь-який з байтів у цілому цілому дорівнює нулю. Цей обчислення має помилковий позитив, коли принаймні один байт перевищує 0x80, але частіше за все він повинен працювати. Якщо це не так (як це в жовтій області), довжина збільшується на розмір вирівнювання.

Якщо будь-який з байтів у 0x81цілому цілому виявляється нулем (або ), то рядок перевіряється байт-байт, щоб визначити позицію нуля.

Це може зробити позабійний доступ, однак, оскільки це в межах вирівнювання, швидше за все, це не буде добре, одиниці відображення пам'яті зазвичай не мають точності рівня байтів.


Ця реалізація є частиною glibc. Система GNU забезпечує захист пам’яті з деталізацією сторінки. Так що так, вирівняне навантаження, що включає будь-які дійсні байти, є безпечним.
Пітер Кордес

size_tне гарантується вирівнювання.
СС Енн

32

Ви хочете, щоб код був правильним, підтримуваним та швидким. Ці фактори мають різне значення:

"правильно" абсолютно важливо.

"maintainable" залежить від того, скільки ви збираєтеся підтримувати код: strlen функціонує у бібліотеці Standard C протягом більше 40 років. Це не зміниться. Тому технічна підтримка є досить неважливою - для цієї функції.

"Швидкий": у багатьох програмах strcpy, strlen тощо використовують значну кількість часу на виконання. Щоб досягти такого ж загального збільшення швидкості, як і ця складна, але не дуже складна реалізація strlen шляхом вдосконалення компілятора потребувала б героїчних зусиль.

Швидкість має ще одну перевагу: Коли програмісти дізнаються, що виклик "strlen" - це найшвидший метод, вони можуть виміряти кількість байтів у рядку, вони більше не спокушаються написати власний код, щоб зробити речі швидшими.

Тож для strlen швидкість набагато важливіша, а ремонтопридатність набагато менш важлива, ніж для більшості кодів, які ви коли-небудь будете писати.

Чому це повинно бути таким складним? Скажімо, у вас є рядок на 1000 байтів. Проста реалізація вивчить 1000 байт. Поточна реалізація, ймовірно, вивчить 64-бітні слова одночасно, що означає 125 64-бітових або восьмибайтових слів. Він може навіть використовувати векторні інструкції, вивчаючи одночасно 32 байти, що було б ще складніше та ще швидше. Використання векторних інструкцій призводить до коду, який є трохи складнішим, але досить простим, перевіряючи, чи потрібен один з восьми байтів у 64-бітовому слові нульових хитрощів. Так що для середніх і довгих рядків цей код можна очікувати приблизно в чотири рази швидше. Для функції, такої ж важливої, як strlen, варто написати більш складну функцію.

PS. Код не дуже портативний. Але це частина бібліотеки Standard C, яка є частиною реалізації - вона не повинна бути портативною.

PPS. Хтось опублікував приклад, коли інструмент для налагодження скаржився на доступ до байтів минулого кінця рядка. Реалізація може бути спроектована таким чином, що гарантує наступне: Якщо p є дійсним вказівником на байт, то будь-який доступ до байту в одному і тому ж вирівнюваному блоці, який був би невизначеним поведінкою відповідно до стандарту C, поверне не визначене значення.

PPPS. Intel додала інструкції до своїх пізніших процесорів, які формують будівельний блок для функції strstr () (знаходження підрядка в рядку). Їх опис викликає глузливість, але вони можуть зробити цю функцію, ймовірно, у 100 разів швидшою. (В основному, маючи масив, що містить "Hello, world!" Та масив b, починаючи з 16 байт "HelloHelloHelloH" і містить більше байтів, він з'ясовує, що рядок a не виникає в b раніше, ніж починається з індексу 15) .


Або ... Якщо я виявлю, що я займаюся великою обробкою на основі рядків і є вузьке місце, я, ймовірно, збираюся реалізувати власну версію Pascal Strings, а не покращувати strlen ...
Baldrickk

1
Ніхто не просить вас покращити strlen. Але зробити це досить добре уникає дурниць, як люди, що реалізують власні рядки.
gnasher729


24

Коротко: перевірка рядкового байта за байтом потенційно буде повільною для архітектур, які можуть одночасно отримувати більшу кількість даних.

Якщо перевірка нульового припинення може бути здійснена на 32 або 64 бітовій основі, це зменшує кількість перевірок, які повинен виконати компілятор. Ось що намагається зробити зв'язаний код, маючи на увазі конкретну систему. Вони роблять припущення щодо адреси, вирівнювання, використання кешу, нестандартних налаштувань компілятора тощо.

Читання байтів у байті, як у вашому прикладі, було б розумним підходом до 8-бітового процесора або під час написання переносного файлу, написаного у стандартному С.

Дивлячись на стандартні ліцензії на C, щоб порадити, як писати швидкий / хороший код, - це не дуже гарна ідея, оскільки він буде непереносним і покладається на нестандартні припущення або погано визначену поведінку. Якщо ви новачок, читання такого коду, ймовірно, буде шкідливішим, ніж навчальний.


1
Звичайно, велика ймовірність, що оптимізатор відкрутить або автоматично векторизує цей цикл, і попередній вибір може тривіально виявити цей шаблон доступу. Чи справді ці хитрощі мають значення для сучасних процесорів, потрібно було б перевірити. Якщо виграш має бути, він, ймовірно, використовує векторні інструкції.
єпископ

6
@russbishop: Ви сподіваєтесь на це, але ні. GCC і кланг абсолютно не здатні до автоматичної векторизації циклів, коли кількість ітерацій не відома перед першою ітерацією. Це включає петлі пошуку або будь-який інший цикл із залежними від даних if()break. ICC може автоматично векторизувати подібні петлі, але IDK наскільки добре це робить з наївним струном. І так, SSE2 pcmpeqb/ pmovmskbце дуже добре для STRLEN, тестування 16 байт за один раз. code.woboq.org/userspace/glibc/sysdeps/x86_64/strlen.S.html - версія SSE2 glibc. Дивіться також це питання .
Пітер Кордес

На жаль, це прикро. Я зазвичай дуже проти UB, але, як ви зазначаєте, струни C вимагають технічного зчитування кінцевого буфера UB, щоб навіть дозволяти векторизацію. Я думаю, те саме стосується ARM64, оскільки він вимагає вирівнювання.
russbishop

-6

Одне важливе, що не було зазначено в інших відповідях, - це те, що ФФС дуже обережно ставиться до того, щоб власний код не перетворив його на проекти GNU. У Стандартах кодування GNU у розділі Посилання на власні програми є попередження про організацію вашої реалізації таким чином, що її не можна переплутати з існуючим власницьким кодом:

Ні в якому разі не посилайтеся на вихідний код Unix для або під час роботи над GNU! (Або до будь-яких інших фірмових програм.)

Якщо у вас є невиразний спогад про внутрішні програми програми Unix, це зовсім не означає, що ви не можете писати його імітації, але намагайтеся організувати імітацію всередині інших рядків, оскільки це, ймовірно, стане детальною інформацією про версія Unix не має значення та не відрізняється від ваших результатів.

Наприклад, утиліти Unix в цілому були оптимізовані для мінімізації використання пам'яті; якщо ви скоріше їдете за швидкістю , ваша програма буде сильно відрізнятися.

(Наголос мій.)


5
Як це відповідає на питання?
SS Anne

1
Питання в ОП було: «чи не працюватиме цей простіший код краще?», І це питання, яке не завжди вирішується з технічних достоїнств. Для такого проекту, як GNU, уникнення юридичних підводних каменів є важливою частиною коду, що "працює краще", а "очевидні" реалізації strlen(), ймовірно, вийдуть схожими або ідентичними існуючому коду. Щось таке «божевільне», як реалізація glibc, неможливо простежити так. Враховуючи, скільки юридичних суперечок було за rangeCheck11 рядків коду! - У боротьбі Google / Oracle я б сказав, що стурбованість FSF виявилася вдалою.
Джек Келлі
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.