Один из моих старых постов о strlcpy недавно вызвал обсуждения на различных форумах. Вероятно, с этим как-то связан выпуск новой версии POSIX. Многие авторы приводили один контраргумент, который я слышал и раньше:

  • В общем случае, когда исходная строка умещается в конечный буфер, strlcpy будет обходить строку только один раз, а strlen + memcpy будут обходить её дважды.

Под этим аргументом скрывается допущение о том, что однократный обход строки выполняется быстрее. И, честно говоря, это вполне разумное допущение. Но справедливо ли оно? Об этом мы и поговорим в статье.

CPU и здравый смысл

У компьютеров нет здравого смысла. Компьютеры удивляют.
Энтони Хоар

Приведённый ниже код взят из openbsd, где и появилась strlcpy (код немного сокращён).

size_t strlcpy(char *dst, const char *src, size_t dsize)
{
    const char *osrc = src;
    size_t nleft = dsize;

    if (nleft != 0) while (--nleft != 0) { /* Копируе as many bytes as will fit. */
        if ((*dst++ = *src++) == '\0')
            break;
    }

    if (nleft == 0) { /* Not enough room in dst, add NUL and traverse rest of src. */
        if (dsize != 0) *dst = '\0'; /* NUL-terminate dst */
        while (*src++) ;
    }

    return(src - osrc - 1);	/* count does not include NUL */
}

Сначала в нём выполняется максимально возможное копирование из src в dst, и если данные приходится урезать из-за недостаточного размера dst, то выполняется обход оставшейся части src, чтобы получить значение strlen(src) для его возврата. То есть если исходная строка умещается, то обход будет выполняться только один раз.

Если же взглянуть на реализацию strlcpy из glibc, то можно сразу заметить. что первая строка выглядит так:

    size_t src_length = strlen (src);

... а далее следует остальной код, в котором для копирования используется memcpy. Это уже разрушает иллюзию о том, что strlcpy выполняет обход строки единожды, такого требования нет, и как вы видите на практике, одна из значимых libc всегда обходит строку дважды, по одному разу в strlen и в memcpy.

Но прежде чем вы откроете баг-репорт glibc из-за её неэффективности, взгляните на бенчмарк многократного копирования 512-байтовой строки в цикле:

512 byte
openbsd:      242us
glibc:         12us

Возможно, строка настолько мала, что этот двойной обход не так важен? Тогда как насчёт строки на 1 МиБ?

1MiB
openbsd:   501646us
glibc:      31793us

Здесь ситуация для версии openbsd становится только хуже, а не лучше. Откровенно говоря, это огромное ускорение возникает благодаря тому, что glibc передаёт всю работу strlen и memcpy, которые в glibc SIMD-оптимизированы. Тем не менее, мы уже видим, что делать что-то дважды, но быстро — это быстрее, чем делать один раз, но медленно.

Сравниваем сравнимое

Чтобы выполнить правильное сравнение, я написал следующую реализацию strlcpy, которая очень близка к реализации glibc, если не считать того, что вызовы strlen и memcpy записаны в циклы for.

size_t bespoke_strlcpy(char *dst, const char *src, size_t size)
{
    size_t len = 0;
    for (; src[len] != '\0'; ++len) {} // strlen() loop

    if (size > 0) {
        size_t to_copy = len < size ? len : size - 1;
        for (size_t i = 0; i < to_copy; ++i) // memcpy() loop
            dst[i] = src[i];
        dst[to_copy] = '\0';
    }
    return len;
}

Важно отметить. что для настоящего сравнения нужно добавить при компиляции -fno-builtin. В противном случае gcc поймёт, что «цикл strlen» можно оптимизировать до вызова strlen, и сгенерирует его. -fno-builtin позволяет избежать этого и делает сравнение честным.

Как же проявит себя эта версия, выполняющая обход src дважды, по сравнению с вариантом openbsd, обходящим src только один раз?

512 byte
openbsd:      237us
bespoke:      139us

Почти в два раза быстрее. А как насчёт строк подлиннее?

1MiB
openbsd:   488469us
bespoke:   277183us

Всё равно примерно вдвое быстрее. Как же так получилось?

Зависимости

Постоянно подчёркивается важность промахов кэша (и с полным на то основанием), но о зависимостях говорят не так часто. У CPU есть несколько ядер, и у каждого ядра есть несколько портов (или логических блоков), способных исполнять команды. Это означает. что если у нас есть подобные команды (на псевдоассемблере, где заглавные буквы обозначают регистр):

A  <- add  B, C
X  <- add  Y, Z
E  <- add  A, X

то вычисления A и X не зависят друг от друга, а значит, могут выполняться параллельно. Однако вычисление E требует результата A и X, а потому не может быть распараллелено. Такой процесс, позволяющий исполнять независимые команды одновременно, называется параллелизмом уровня команд (instruction-level-parallelism, ILP). И его криптонитом становятся зависимости.

Если мы попытаемся профилировать нашу самодельную версию strlcpy, то заметим, что почти 100% времени CPU тратится на «цикл strlen», в то время как цикл copy практически свободен. И в самом деле, если заменить «вызов strlen» настоящим вызовом strlen (напоминание: в glibc она SIMD-оптимизирована), то самодельная версия достаточно неплохо начинает конкурировать с версией glibc, хотя мы не используем оптимизированную memcpy. Чтобы понять, почему это происходит, давайте рассмотрим «цикл strlen», написанный в многословном стиле:

len = 0;
while (true) {
    if (src[len] == '\0')
        break;  // <- это влияет на следующую итерацию
    else
        ++len;
}

В показанном выше цикле то, будет ли выполняться следующая итерация цикла, зависит от результата предыдущей итерации (имело ли src[len]  значение nul или нет). Мы расплачиваемся за это в нашем цикле strlen. Но наш цикл memcpy не имеет таких привносимых циклом зависимостей, текущая итерация происходит вне зависимости от того, что происходило в предыдущей.

for (size_t i = 0; i < to_copy; ++i) // memcpy() loop
    dst[i] = src[i]; // <- НЕ зависит от предыдущей итерации

Так как в версии openbsd длина и цикл копирования объединены, решение о копировании следующего байта принимается в зависимости от значения байта предыдущей итерации.

while (--nleft != 0) {  // цикл копирования openbsd
    if ((*dst++ = *src++) == '\0') // <- выбранное здесь ветвление влияет на следующую итерацию
        break;
}

По сути, затраты на эту зависимость теперь накладываются не только на вычисление длины, но и на операцию копирования. Хуже того, зависимости не только сложны для CPU, они сложны и для компилятора в оптимизации/автовекторизации, что приводит к снижению качества генерируемого кода, создавая кумулятивный эффект.

Дополнение: не избавляйтесь от длины

Секрет создания быстрых программ заключается в том, чтобы они практически ничего не делали.
- Майк Хэртел, why GNU grep is fast

Два года назад, когда я писал статью о strlcpy, я всё ещё был уверен, что строки с завершающим нулём в конце — это «нормально», и что проблему вызывает низкое качество стандартной библиотеки. Но я заметил, что даже при наличии более качественных процедур обработки таких строк при работе с ними на написание кода тратится несоразмерно большой объём мыслительных усилий и возникает слишком много багов. С тех пор я сделал два важных вывода:

  • Длина строки — это бесценная информация.

Без знания длины строка становится больше похожей на связанный список, заставляя нас использовать паттерн последовательного доступа, а не на массив с возможностью произвольного доступа. Многие популярные функции работы со строками можно удобнее выразить (а значит, и совершить меньше ошибок), когда длину можно узнать малозатратным образом. С другой стороны, строки с завершающим нулём в конце мотивируют постоянно отказываться от этой ценной информации, что приводит к необходимости беспричинно вычислять её снова, и снова, и снова (при этом всегда вспоминается инцидент с экраном загрузки GTA [перевод на Хабре]).

  • Возможность иметь подстроки без копирования — это огромное преимущество.

Это позволяет избавиться от множества беспричинных копий (то есть повышает эффективность), а также от распределения памяти (то есть избавляет от ненужного управления памятью). И в результате большая доля логики и кода, необходимых для обработки строк с завершающим нулём в конце, просто исчезает.

Помня об этих двух пунктах, сегодня я просто использую sized string (что-то в стиле std::string_view языка C++) и преобразую их в nul-string только тогда, когда этого требует внешний API. Эта тема заслуживает отдельной статьи, поэтому здесь я упоминаю её только по касательной.

Но здорово то, что вне группы пользователей C, сильно подверженной логическому искажению «умолчания» (если что-то используется по умолчанию, то использовать это правильно и нужно), подавляющее число разработчиков более-менее осознало, что строки с завершающим нулём были ошибкой. Это очевидно, если взглянуть на большинство других языков программирования, в том числе и многие новые, предназначенные для системного программирования: в них nul-string не используются по умолчанию (если вообще используются). Даже языки, наследующие от C, отходят от применения nul-string — вспомним, например, string_view из C++.

Заключение

При обсуждении производительности важно чётко определиться, говорим ли мы о ней в научном смысле, или в практическом, потому что CPU не заботят здравый смысл и «большое O». Современные CPU невероятно сложны и полны сюрпризов. Поэтому скорость алгоритма зависит не только от высокоуровневых алгоритмических факторов: необходимо также принимать во внимание и низкоуровневые факторы, например, промахи кэша, ILP, ошибочное прогнозирование ветвления и так далее. Многие вещи, которые с точки зрения здравого смысла могут казаться быстрыми, на практике оказываются медленнее, и наоборот.

Комментарии (10)


  1. SIISII
    24.08.2024 10:29
    +5

    Ну, лично я изначально считал порочной идею не хранить длину строки в явном виде, а заканчивать строку нулевым символом. Да, длина строки будет ограничена максимальным значением счётчика, но если выделить под него 4 байта, то вряд ли найдётся разумный случай, где этой длины для строки будет мало (4 Гбайта или 4 Гсимвола в зависимости от того, как трактовать значение длины, что в данном случае вторично). Ну а выделение лишних трёх байтов даже на машинах с адресным пространством в 64 Кбайта (в т.ч. PDP-11, где, по сути, и появился Уних вместе с Це) -- отнюдь не гигантский перерасход памяти, не говоря о том, что на таких архитектурах можно было бы выделять под длину два байта, а не четыре -- всё равно строка в памяти не может быть длинней, чем этой памяти можно адресовать. (Ну а где каждый байт на счету в буквальном смысле -- пишите на ассемблере, и будет вам счастье.)

    А вот с заключением автора не очень-то согласен. Противоречия возникают не между реальным поведением программ на реальных процах и "здравым смыслом", а между реальным поведением и ожиданием программиста, плохо понимающего (или вообще не понимающего), как в реальности работает железо. Когда такое понимание есть, сюрпризов будет намного меньше.


    1. ptr128
      24.08.2024 10:29

      выделение лишних трёх байтов даже на машинах с адресным пространством в 64 Кбайта (в т.ч. PDP-11, где, по сути, и появился Уних вместе с Це) -- отнюдь не гигантский перерасход памяти

      Идеология C закладывалась на PDP-7, где в базовом варианте было всего 4К слов. Но речь даже не об этом. Популярность C приобрел потому, что подходил не только для весьма мощных для того времени PDP-11, но так же и для микропроцессоров и микроконтроллеров.

      Для понимания, первый ZX Spectrum имел всего 16К RAM, из которых почти половина была занята под видеобуфер. Atari 800 имела только 8К. МК 8048 имел всего 128 байт RAM, а до сих пор встречающийся 8051 - 256 байт.

      Даже сейчас на маломощных МК можно встретить эти же 128 или 256 байт оперативной памяти. Наапример, в МК Padauk.

      Так что будьте снисходительны к Ричи. Тогда действительно каждый байт считали. И сейчас на некоторых МК тоже приходится это делать.


    1. aamonster
      24.08.2024 10:29
      +1

      Вы просто выросли в те времена, когда под длину строки выделяли целый int, а не byte :-) (Turbo Pascal и более ранние паскали на 8-битных машинках).


  1. datacompboy
    24.08.2024 10:29

    Простите... А где противоречие? strlen+memcpy = O(n); комбинированный цикл = O(n)...


    1. UranusExplorer
      24.08.2024 10:29
      +1

      А причем здесь вообще О-большое? O-большое говорит о том, насколько меняется время выполнения при увеличении n, но ничего не говорит о разнице в производительности реализаций при одинаковом n. А разница там может быть очень существенная, и именно этот вопрос автор в статье и исследовал.


      1. datacompboy
        24.08.2024 10:29
        +1

        При том, что умозрительная оценка сложности заканчивается на O-большом. Дальше начинаются эксперименты, результат которых валиден прямо тогда, когда ты их снял... :)

        Оценка константы внутри умозрительная -- это гадание на кофейной гуще.

        Просто фраза "потому что CPU не заботят здравый смысл и «большое O»" несколько далека от истины. Большое О очень даже заботит, и квадратичный алгоритм будет квадратичным, и с ростом входных данных превратится в нечто очень медленное -- см.упомянуют статью про GTA.

        А "здравый смысл" должен говорить -- "если важно -- надо не только прикинуть, но и замерить, так как любое предположение следует попытаться опровергнуть, прежде чем считать его истинным". Вместо этого люди склонны делать эксперименты, поддерживающие их точку зрения...


        1. aamonster
          24.08.2024 10:29

          Ну сравниваем два алгоритма с одинаковым O, различаются константой при n. И значение этой константы оказывается контринтуитивным.


  1. apro
    24.08.2024 10:29
    +3

    Так автор же допустил ошибку в опциях gcc. Читал об этом на reddit. Автор конечно отключил автозамену циклов на memcpy и strlen, использующих SIMD, с помощью -fno-builtin, но не отключал автовекторизацию (-fno-tree-vectorize). Поэтому его дальнейшие рассуждения после bespoke_strlcpy следуют воспринимать весьма скептически.


  1. Sadler
    24.08.2024 10:29

    Помня об этих двух пунктах, сегодня я просто использую sized string (что-то в стиле std::string_view языка C++) и преобразую их в nul-string только тогда, когда этого требует внешний API. 

    Тоже прельщает, но очень неохота отказываться практически полностью от стандартной библиотеки, либо конвертировать туда-сюда. Можно, конечно, реализовать комбинированный подход: и записывать длину, и ставить \0 в конце, но это будет работать не слишком хорошо для бинарных строк, где нули могут быть в середине, и этим создаст больше путаницы. Так что пока просто нарулил себе библиотеку более безопасных с т.з. использования функций для работы с nul-строками, и на этом успокоился.

    В своей библиотеке работы со строками я использую строки в динамической памяти, стараюсь избавиться от деструктивных функций и прочей математики указателей. При этом немного раздражает необходимость проработки каждого случая выделения памяти. Можно было, конечно, использовать сугубо статичные буферы, но это лишний расход памяти и ограниченность миллионом констант размеров буферов. И всё равно остаётся необходимость отслеживания overflow (truncate) буферов.

    Я пробовал в своей библиотеке использовать выходной аргумент error, в котором ошибка "проваливается", и можно не проводить проверку после каждой операции

    Пример
    int str_to_int(const char* str, int default_value, string_error_t* error) {
      char endptr;
      errno = 0;
      long result = strtol(str, &endptr, 10);
    
      if (endptr == str || *endptr != '\0' || errno || result > INT_MAX || result < INT_MIN) {
        if (error) {
          *error |= STRING_ERROR_UNSPECIFIED;
        }
        return default_value;
      }
    
      return (int)result;
    }

    Плюсы у такого подхода есть, но на подкорке постоянно сидит "если я забуду заинициализировать error, то цепочка действий над строками может разваливаться как попало. longjmp -- тоже вариант, но мне было бы ещё ленивее его менеджить.


    1. aamonster
      24.08.2024 10:29
      +1

      Ага, вначале строки без \0, потом string_view и иммутабельные строки, и вот ты уже пишешь на Хаскеле и используешь ropes :-)