Во время работы в Headlands Technologies мне посчастливилось написать несколько утилит для упрощения создания высокопроизводительного кода на C++. Эта статья предлагает обобщенный обзор одной из этих утилит — OutOfLine.


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


Первоначальный (упрощённый) вариант может выглядеть так:


class UnlinkingFD {
  std::string path;
 public:
  int fd;

  UnlinkingFD(const std::string& p) : path(p) {
    fd = open(p.c_str(), O_RDWR, 0);
  }
  ~UnlinkingFD() { close(fd); unlink(path.c_str()); }
  UnlinkingFD(const UnlinkingFD&) = delete;
};

И это хороший, логически обоснованный дизайн. Он полагается на RAII для автоматического освобождения дескриптора и удаления ссылки. Можно создать большой массив таких объектов, поработать с ними, а когда массив прекратит существование, объекты сами очистят все, что было нужно в процессе работы.


Но что насчёт производительности? Предположим, fd используется очень часто, а path только при удалении объекта. Сейчас массив состоит из объектов размером 40 байт, но часто используются только 4 байта. Значит, будет больше промахов в кеше, поскольку нужно "пропускать" 90% данных.


Одним из частых решений такой проблемы является переход от массива структур к структуре массивов. Это обеспечит желаемую производительность, но ценой отказа от RAII. Есть ли вариант, сочетающий преимущества обоих подходов?


Простым компромиссом может быть замена std::string размером 32 байта на std::unique_ptr<std::string>, размер которого только 8 байт. Это позволит уменьшить размер нашего объекта с 40 байт до 16 байт, что является большим достижением. Но это решение по прежнему проигрывает использованию нескольких массивов.


OutOfLine — это инструмент позволяющий без отказа от RAII полностью переместить редко используемые (cold) поля вовне объекта. OutOfLine используется в качестве CRTP базового класса, поэтому первым аргументом шаблона должен быть дочерний класс. Второй аргумент — тип редко используемых (холодных) данных, которые связаны с часто используемым (основным) объектом.


struct UnlinkingFD : private OutOfLine<UnlinkingFD, std::string> {
  int fd;

  UnlinkingFD(const std::string& p) : OutOfLine<UnlinkingFD, std::string>(p) {
    fd = open(p.c_str(), O_RDWR, 0);
  }
  ~UnlinkingFD();
  UnlinkingFD(const UnlinkingFD&) = delete;
};

Так что же из себя представляет этот класс?


template <class FastData, class ColdData>
class OutOfLine {

Базовая идея реализации заключается в использовании глобального ассоциативного контейнера, который сопоставляет указатели на основные объекты и указатели на объекты содержащие холодные данные.


  inline static std::map<OutOfLine const*, std::unique_ptr<ColdData>> global_map_;

OutOfLine может быть использован с любым типом холодных данных, экземпляр которых создается и связывается с основным объектом автоматически.


  template <class... TArgs>
  explicit OutOfLine(TArgs&&... args) {
    global_map_[this] = std::make_unique<ColdData>(std::forward<TArgs>(args)...);
  }

Удаление основного объекта влечет автоматическое удаление связанного холодного объекта:


  ~OutOfLine() { global_map_.erase(this); }

При перемещении (move constructor/move assignment operator) основного объекта, соответствующий ему холодный объект будет автоматически связан с новым основным объектом-преемником. Как следствие, не следует обращаться к холодным данным перемещённого (moved-from) объекта.


  explicit OutOfLine(OutOfLine&& other) { *this = other; }
  OutOfLine& operator=(OutOfLine&& other) {
    global_map_[this] = std::move(global_map_[&other]);
    return *this;
  }

В приведенном примере реализации OutOfLine сделан некопируемым для простоты. При необходимости операции копирования легко добавить, в них достаточно лишь создать и связать копию холодного объекта.



  OutOfLine(OutOfLine const&) = delete;
  OutOfLine& operator=(OutOfLine const&) = delete;

Теперь, чтобы это было действительно полезно, хорошо бы иметь доступ к холодным данным. При наследовании от OutOfLine класс получает константный и неконстантный методы cold():


  ColdData& cold() noexcept { return *global_map_[this]; }
  ColdData const& cold() const noexcept { return *global_map_[this]; }

Они возвращают соответствующий тип ссылки на холодные данные.


Вот почти и все. Такой вариант UnlinkingFD будет иметь размер 4 байта, предоставит дружественный по отношению к кешу доступ к полю fd и сохранит преимущества RAII. Вся работа, связанная с жизненным циклом объекта, полностью автоматизирована. Когда основной часто используемый объект перемещается, редко используемые холодные данные перемещаются вместе с ним. Когда основной объект удаляется, удаляется и соответствующий ему холодный объект.


Иногда, однако, ваши данные сговариваются, чтобы усложнить вам жизнь — и вы сталкиваетесь с ситуацией в которой основные данные должны быть созданы первыми. Например, они нужны для конструирования холодных данных. Появляется необходимость создавать объекты в обратном порядке относительного того, что предлагает OutOfLine. Для таких случаев нам пригодится "запасной ход" для контроля порядка инициализации и деинициализации.


  struct TwoPhaseInit {};
  OutOfLine(TwoPhaseInit){}
  template <class... TArgs>
  void init_cold_data(TArgs&&... args) {
    global_map_.find(this)->second = std::make_unique<ColdData>(std::forward<TArgs>(args)...);
  }
  void release_cold_data() { global_map_[this].reset(); }

Это ещё один конструктор OutOfLine, который можно использовать в дочерних классах, он принимает тег типа TwoPhaseInit. Если создать OutOfLine таким образом, то холодные данные не будут инициализированы, а объект останется наполовину сконструированным. Для завершения двухфазного конструирования нужно вызвать метод init_cold_data (передав в него аргументы необходимые для создания объекта типа ColdData). Помните, что нельзя вызывать .cold() у объекта, холодные данные которого еще не инициализированы. По аналогии, холодные данные можно удалить досрочно, до выполнения деструктора ~OutOfLine, вызвав release_cold_data.


}; // end of class OutOfLine

Вот теперь все. Итак, что эти 29 строчек кода нам дают? Они представляют собой ещё один возможный компромисс между производительностью и простотой использования. В случаях когда у вас есть объект, часть членов которого используется значительно чаще других, OutOfLine может послужить легким в эксплуатации способом оптимизации кеша, ценой значительного замедления доступа к редко используемым данным.


Мы смогли применить эту технику в нескольких местах — довольно часто возникает потребность дополнить интенсивно используемые рабочие данные дополнительными метаданными, которые необходимы при завершении работы, в редких или неожиданных ситуациях. Будь то информация о пользователях установивших соединение, торговом терминале с которого пришел заказ, или дескриптор аппаратного ускорителя, занятого обработкой биржевых данных — OutOfLine сохранит кеш чистым, когда вы находитесь в критической части вычислений (critical path).


Я подготовил тест, чтобы вы могли увидеть и оценить разницу.


Сценарий Время (нс)
Холодные данные в основном объекте (первоначальный вариант) 34684547
Холодных данные полностью удалены (лучший сценарий ) 2938327
С использованием OutOfLine 2947645

Я получил примерно 10-ти кратное ускорение при использовании OutOfLine. Очевидно, что этот тест разработан для демонстрации потенциала OutOfLine, однако он также показывает насколько существенное влияние на производительность способна оказать оптимизация кеша, равно как и то, что OutOfLine позволяет эту оптимизацию получить. Поддержание кеша свободным от редко используемых данных может обеспечить сложно измеряемое комплексное улучшение показателей остального кода. Как и всегда при оптимизации, доверяйте измерениям больше чем предположениям, тем не менее надеюсь что OutOfLine окажется полезным инструментом в вашей копилке утилит.


Примечание от переводчика


Код, приведенный в статье, служит для демонcтрации идеи и нерепрезентативен по отношению к продакшн коду.

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


  1. svr_91
    31.08.2018 11:14

    А зачем использовать unique_ptr в map? Нельзя просто класть данные по типу?


    1. Hokum Автор
      31.08.2018 11:30

      Для большей гибкости, так как в общем случае тип ColdData может быть не перемещаемым и не копируемым.


      1. svr_91
        31.08.2018 11:36

        Такие типы довольно хорошо в мапу ложаться. Для этого можно использовать

        m.emplace(std::piecewise_construct, ...)


        1. Hokum Автор
          31.08.2018 11:54

          А как их перемещать при перемещении основного объекта?


          1. svr_91
            31.08.2018 12:10

            Ну, если ColdData не поддерживает перемещение, то может и не поддерживать перемещение основного объекта? Хотя да, об этом я не подумал


          1. Antervis
            31.08.2018 20:15
            -2

            мапе ж не нужно перемещать/копировать объекты, достаточно сравнивать, емплейсить ну и удалять


            1. Prototik
              01.09.2018 00:56

              Да правильно, нахрена что-то получать из мапы — достаточно добавлять и удалять.


              1. Antervis
                01.09.2018 12:52

                «получать из мапы» можно и по ссылке. Разумеется, подумав о владении


          1. 0xd34df00d
            01.09.2018 02:42

            Магией C++17: вот.


            1. Hokum Автор
              01.09.2018 10:17

              Здорово! Жаль, что GCC только начиная с 7.1 это поддерживает.


  1. maydjin
    31.08.2018 12:20

    А доступ к элементам мэп не защищён блокировками потому, что… ?


    1. Kobalt_x
      31.08.2018 12:31

      потому что блокировки в такой ситуации(вычислительно-интенсивном алгоритме), при большом количестве потоков ещё большее зло, Если уж радеем за каждый flops то уж лучше concurrent_unordered_map из tbb


      1. maydjin
        31.08.2018 12:38

        Ну чего лучше это понятно. Вопрос почему автор об этом не сказал ни слова. В любом случае *concurent*map* from *.h* обычно стоит таки не бесплатно, т.к. потокобезопасность не волшебными гномиками достигается. Соответственно, автор либо показал не всё решение, либо его бенчмарк не отражает картину в целом, либо в реализации есть банальная ошибка. Либо не описал ограничения подхода.


        Я своими глазами видел в горячем mission critical коде подобный подход с доступом к мапе без блокировки, и как ни странно, он работал годами, ну иногда баги там какие то странные всплывали раз в полгода...


      1. Hokum Автор
        31.08.2018 12:56

        Возможно в их случае это не нужно, например, все объекты создаются в одном потоке при старте программы, и уничтожаются в том же потоке при завершении. Доступ к «холодным» данным осуществяется только в исключительных случаях, которые могут обрабатываться том же потоке, где и были созданы (мало ли как у них построена система).

        Так как подразумевается, что доступ к «холодным» данным явление редкое, то добавление блокировок не сильно уменьшит производительность, а параллелится, скорей всего обработка массивов с «основными» данными.


        1. maydjin
          31.08.2018 13:32

          Вот эти слова с ограничениями хотелось бы видеть в статье или в переводе. Можно например у авторов спросить — как так?


    1. RPG18
      31.08.2018 12:41

      Может быть у них все работает в одном потоке?


      1. maydjin
        31.08.2018 12:42

        Тогда, считать наносекунды на фоне i/o слегка странно.


        1. RPG18
          31.08.2018 12:45

          Согласен, что пример неудачный, но подход то рабочий.


          1. maydjin
            31.08.2018 12:47

            На самом деле, я даже уверен что блокировка не попортит avg, немножко больше вылетов в распределении будет и то в менее синтетических тестах нежели у автора.


            1. RPG18
              31.08.2018 12:58

              Блокировки будут при создании/удалении элементов, которые лежат в векторе. Это тоже накладывают нюансы. А оптимизация на одном потоке при i/o не такая уж и редкая задача. Однопоточные сервисы никуда не делись.


              1. maydjin
                31.08.2018 13:25

                Я скорее к тому, что распараллеливание такого сервиса даст более драматичный прирост производительности. И тогда, остаётся только кейс с одно ядерными системами, что довольно редко в наше время. Второй момент — речь идёт о поддержании чистоты кэша, но, если на этом же ядре будет происходить ввод/вывод то все потуги со структурами массивов кажутся каплей в море ибо кэшем будет в общем то заправлять ядро. Т.е. для одноядерного сценария с i/o, лично мне, это кажется оптимизацией уровня писать на асме вместо C.


                1. RPG18
                  31.08.2018 13:53

                  Насколько понимаю, разработчики tarantool стараются оптимизировать расположения элементов в памяти.


                  1. maydjin
                    31.08.2018 14:03

                    Я опыта с этой поделкой не имел, но по описанию в интернете это in memory database? Там i/o сильно условный и в юзерспейсе происходит нет?


    1. a-tk
      31.08.2018 13:53

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


      1. maydjin
        31.08.2018 14:04

        Звучит разумно.


  1. Videoman
    31.08.2018 13:23

    Кроме многопоточности, автор, также, ничего не говорит о UB в случае использования таких «волшебных» объектов совместно с другими глобальными объектами, т.к. порядок их создания и разрушения не определен. На мой взгляд, тут на лицо проблема архитектуры классов, когда в обертку файлового дескриптора, зачем-то, запихнули path, который не используется 90% времени, а может быть вообще не применим к такому объекту. В этом случае, я бы рекомендовал создать мапу явно, если она нужна, и работать с ней отдельно от обертки, которая вполне себе самодостаточная сущность, на мой взгляд. В общем, такое решение, как «затычка» в конкретной ситуации, может быть и имеет право на жизнь, но паттерном я бы такой подход не стал называть.


    1. maydjin
      31.08.2018 13:28

      Точно, композиция лучше и агрегации и наследования, почти всегда. Вот если бы пул для холодных данных можно было бы задавать из вне, было бы интереснее.


  1. ilammy
    31.08.2018 15:47

    Мне интересно, зачем понадобилось держать все эти файлы в файловой системе? Ведь файлы можно открыть, получить дескриптор, и тут же удалить: файл будет жить до последнего закрытого файлового дескриптора. В Linux ещё есть O_TMPFILE, с помощью которого можно избежать выдумывания имени для файла. При необходимости другие процессы могут получить доступ к таким файлам через procfs (или её аналог). Через неё же файл можно вернуть в файловую систему, если потребуется.


  1. Hokum Автор
    31.08.2018 18:54

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


  1. mayorovp
    31.08.2018 18:58

    Мне кажется, API можно сильно упростить если вместо ColdData& выставлять наружу std::unique_ptr<ColdData>&.


    В частности, пропадут TwoPhaseInit, init_cold_data и reset_cold_data


  1. iperov
    31.08.2018 20:56
    -6

    после 15 лет кодинга на С++, я уже не могу на него смотреть. А динозавры всё еще придумывают новые способы как прострелить себе ногу. Холодные, горячие, бред.


    1. Fedcomp
      01.09.2018 11:57

      Что думаете о Rust?


  1. voidnugget
    01.09.2018 03:38
    -1

    Такой же принцип используется в golang'e, только там ещё буфера (структуры) сортируются по частоте обращений — возможно будет полезно для вашего случая, так как там получается даже более чем в 10 раз быстрее.