Стандарт C ++ предписывает низкую производительность для iostreams, или я просто имею дело с плохой реализацией?

197

Каждый раз, когда я упоминаю о низкой производительности iostreams стандартной библиотеки C ++, меня встречает волна недоверия. Тем не менее, у меня есть результаты профилировщика, показывающие большое количество времени, проведенного в коде библиотеки iostream (полная оптимизация компилятора), и переключение с iostreams на специфичные для ОС API-интерфейсы ввода-вывода и настраиваемое управление буфером дает улучшение порядка.

Какую дополнительную работу выполняет стандартная библиотека C ++, требуется ли она по стандарту и полезна ли она на практике? Или некоторые компиляторы предоставляют реализации iostreams, которые конкурируют с ручным управлением буфером?

Ориентиры

Чтобы начать работу, я написал несколько коротких программ для внутренней буферизации iostreams:

Обратите внимание , что ostringstreamи stringbufверсии работать меньше итераций , потому что они намного медленнее.

На ideone, то ostringstreamесть примерно в 3 раза медленнее , чем std:copy+ back_inserter+ std::vector, и примерно в 15 раз медленнее , чем memcpyв сырьевой буфер. Это похоже на профилирование до и после, когда я переключил свое реальное приложение на пользовательскую буферизацию.

Все это буферы в памяти, поэтому медлительность iostreams не может быть обвинена в медленном вводе-выводе диска, слишком большом сбрасывании, синхронизации с stdio или любых других вещах, которые люди используют, чтобы оправдать наблюдаемую медлительность стандартной библиотеки C ++ iostream.

Было бы неплохо увидеть тесты для других систем и прокомментировать то, что делают обычные реализации (такие как libc ++ gcc, Visual C ++, Intel C ++) и сколько накладных расходов предусмотрено стандартом.

Обоснование этого теста

Многие люди правильно отметили, что iostreams чаще используются для форматированного вывода. Однако они также являются единственным современным API, предоставляемым стандартом C ++ для доступа к двоичным файлам. Но настоящая причина для выполнения тестов производительности внутренней буферизации заключается в типичном форматированном вводе / выводе: если iostreams не может поддерживать контроллер диска с необработанными данными, как они могут поддерживать, когда они также отвечают за форматирование?

Контрольные сроки

Все это на итерацию внешнего kцикла ( ).

На ideone (gcc-4.3.4, неизвестная ОС и оборудование):

  • ostringstream: 53 миллисекунды
  • stringbuf: 27 мс
  • vector<char>и back_inserter: 17,6 мс
  • vector<char> с обычным итератором: 10,6 мс
  • vector<char> проверка итератора и границ: 11,4 мс
  • char[]: 3,7 мс

На моем ноутбуке (Visual C ++ 2010 x86, cl /Ox /EHscWindows 7 Ultimate, 64-разрядная, Intel Core i7, 8 ГБ ОЗУ):

  • ostringstream: 73,4 миллисекунды, 71,6 мс
  • stringbuf: 21,7 мс, 21,3 мс
  • vector<char>и back_inserter: 34,6 мс, 34,4 мс
  • vector<char> с обычным итератором: 1,10 мс, 1,04 мс
  • vector<char> проверка итератора и границ: 1,11 мс, 0,87 мс, 1,12 мс, 0,89 мс, 1,02 мс, 1,14 мс
  • char[]: 1,48 мс, 1,57 мс

Visual C ++ 2010 x86, с профилем Guided Optimization cl /Ox /EHsc /GL /c, link /ltcg:pgi, бег, link /ltcg:pgo, меры:

  • ostringstream: 61,2 мс, 60,5 мс
  • vector<char> с обычным итератором: 1,04 мс, 1,03 мс

Тот же ноутбук, та же ОС, используя Cygwin GCC 4.3.4 g++ -O3:

  • ostringstream: 62,7 мс, 60,5 мс
  • stringbuf: 44,4 мс, 44,5 мс
  • vector<char>и back_inserter: 13,5 мс, 13,6 мс
  • vector<char> с обычным итератором: 4,1 мс, 3,9 мс
  • vector<char> проверка итератора и границ: 4,0 мс, 4,0 мс
  • char[]: 3,57 мс, 3,75 мс

Тот же ноутбук, Visual C ++ 2008 SP1, cl /Ox /EHsc:

  • ostringstream: 88,7 мс, 87,6 мс
  • stringbuf: 23,3 мс, 23,4 мс
  • vector<char>и back_inserter: 26,1 мс, 24,5 мс
  • vector<char> с обычным итератором: 3,13 мс, 2,48 мс
  • vector<char> проверка итератора и границ: 2,97 мс, 2,53 мс
  • char[]: 1,52 мс, 1,25 мс

Тот же ноутбук, 64-битный компилятор Visual C ++ 2010:

  • ostringstream: 48,6 мс, 45,0 мс
  • stringbuf: 16,2 мс, 16,0 мс
  • vector<char>и back_inserter: 26,3 мс, 26,5 мс
  • vector<char> с обычным итератором: 0,87 мс, 0,89 мс
  • vector<char> проверка итератора и границ: 0,99 мс, 0,99 мс
  • char[]: 1,25 мс, 1,24 мс

РЕДАКТИРОВАТЬ: Выполнить все дважды, чтобы увидеть, насколько последовательными были результаты. Довольно последовательное ИМО.

ПРИМЕЧАНИЕ. На моем ноутбуке, поскольку я могу сэкономить больше процессорного времени, чем позволяет ideone, я установил число итераций равным 1000 для всех методов. Это означает, что ostringstreamи vectorперераспределение, которое происходит только на первом проходе, должно мало влиять на конечные результаты.

РЕДАКТИРОВАТЬ: Упс, обнаружил ошибку в vector-with-обычно-итераторе, итератор не был продвинут, и поэтому было слишком много попаданий в кэш. Мне было интересно, как это vector<char>было лучше char[]. Это не имело большого значения, но vector<char>все же быстрее, чем char[]под VC ++ 2010.

Выводы

Буферизация выходных потоков требует трех шагов каждый раз, когда добавляются данные:

  • Убедитесь, что входящий блок соответствует доступному буферному пространству.
  • Скопируйте входящий блок.
  • Обновите указатель конца данных.

Последний фрагмент кода, который я опубликовал, « vector<char>простой итератор плюс проверка границ», не только делает это, но также выделяет дополнительное пространство и перемещает существующие данные, когда входящий блок не подходит. Как отметил Клиффорд, буферизация в классе файлового ввода-вывода не должна была бы этого делать, она просто очищает текущий буфер и использует его повторно. Так что это должна быть верхняя граница стоимости буферизации вывода. И это именно то, что нужно для создания рабочего буфера в памяти.

Так почему же в stringbuf2,5 раза медленнее на идеоне и как минимум в 10 раз медленнее, когда я его тестирую? Он не используется полиморфно в этом простом микропроцессоре, поэтому это не объясняется.

Ben Voigt
источник
24
Вы пишете миллион символов по одному и удивляетесь, почему это медленнее, чем копирование в предварительно выделенный буфер?
Анон.
20
@Anon: я буферирую четыре миллиона байтов по четыре за раз, и да, мне интересно, почему это медленно. Если std::ostringstreamон недостаточно умен, чтобы экспоненциально увеличивать размер буфера, как это std::vectorпроисходит, это (A) глупо и (B) то, о чем должны думать люди, думающие о производительности ввода-вывода. В любом случае, буфер используется повторно, он не перераспределяется каждый раз. И std::vectorтакже использует динамически растущий буфер. Я пытаюсь быть честным здесь.
Бен Фойгт
14
Какую задачу вы на самом деле пытаетесь оценить? Если вы не используете какие-либо функции форматирования ostringstreamи хотите максимально возможную производительность, то вам следует перейти прямо к этому stringbuf. Предполагается, что ostreamклассы связывают воедино функциональность форматирования с учетом локали с гибким выбором буфера (файл, строка и т. Д.) rdbuf()И его интерфейс виртуальной функции. Если вы не выполняете никакого форматирования, тогда этот дополнительный уровень косвенности будет выглядеть пропорционально дороже по сравнению с другими подходами.
CB Bailey
5
+1 за правду соч. Мы получили ускорение на порядок или величину благодаря переходу от на ofstreamк fprintfпри выводе информации регистрации, включающей удвоения. MSVC 2008 на WinXPsp3. Iostreams это просто собака медленно.
KitsuneYMG
6
Вот некоторые тесты на сайте комитета: open-std.org/jtc1/sc22/wg21/docs/D_5.cpp
Йоханнес Шауб - litb

Ответы:

49

Не отвечая конкретно на ваш вопрос, а на заголовок: в Техническом отчете о производительности C ++ за 2006 год есть интересный раздел о IOStreams (с.68). Наиболее актуальным для вашего вопроса является раздел 6.1.2 («Скорость выполнения»):

Поскольку определенные аспекты обработки IOStreams распределены по нескольким аспектам, представляется, что Стандарт требует неэффективной реализации. Но это не тот случай - используя некоторую форму предварительной обработки, можно избежать значительной части работы. С немного более умным компоновщиком, чем обычно используется, можно устранить некоторые из этих недостатков. Это обсуждается в §6.2.3 и §6.2.5.

Поскольку отчет был написан в 2006 году, можно было бы надеяться, что многие рекомендации будут включены в существующие компиляторы, но, возможно, это не так.

Как вы упомянули, фасеты могут не отображаться write()(но я бы не стал это принимать вслепую). Так что же особенность? Запуск GProf для вашего ostringstreamкода, скомпилированного с помощью GCC, дает следующую разбивку:

  • 44,23% в std::basic_streambuf<char>::xsputn(char const*, int)
  • 34,62% ​​в std::ostream::write(char const*, int)
  • 12,50% в main
  • 6,73% в std::ostream::sentry::sentry(std::ostream&)
  • 0,96% в std::string::_M_replace_safe(unsigned int, unsigned int, char const*, unsigned int)
  • 0,96% в std::basic_ostringstream<char>::basic_ostringstream(std::_Ios_Openmode)
  • 0,00% в std::fpos<int>::fpos(long long)

Таким образом, большая часть времени затрачивается на то xsputn, что в конечном итоге требует std::copy()после множества проверок и обновления позиций курсора и буферов (посмотрите c++\bits\streambuf.tccдетали).

Я считаю, что вы сосредоточились на худшем случае. Вся выполняемая проверка будет составлять небольшую часть всей выполненной работы, если вы работаете с достаточно большими порциями данных. Но ваш код перемещает данные по четыре байта за раз и каждый раз несет все дополнительные расходы. Ясно, что в реальной жизни этого избежать не стоит - подумайте о том, насколько незначительным был бы штраф, если бы writeон вызывался для массива с 1-м целым числом, а не с 1-м разом с одним целым числом. И в реальной жизни можно было бы по достоинству оценить важные функции IOStreams, а именно его безопасный для памяти и безопасный для типов дизайн. Такие преимущества имеют свою цену, и вы написали тест, в котором эти затраты влияют на время выполнения.

beldaz
источник
Звучит как хорошая информация для будущего вопроса о производительности форматированной вставки / извлечения iostreams, который я, вероятно, скоро задам. Но я не верю, что есть какие-либо аспекты, связанные с ostream::write().
Бен Фойгт
4
+1 для профилирования (это машина Linux, я полагаю?). Тем не менее, я фактически добавляю четыре байта за раз (на самом деле sizeof i, но все компиляторы, с которыми я тестирую, имеют 4 байта int). И это не кажется мне таким уж нереальным, как вы думаете, какой размер блока передается при каждом вызове xsputnв типичном коде stream << "VAR: " << var.x << ", " << var.y << endl;.
Бен Фойгт
39
@beldaz: Этот «типичный» пример кода, который вызывает только xsputnпять раз, вполне может быть внутри цикла, который записывает файл длиной 10 миллионов строк. Передача данных в iostreams большими порциями - намного менее реальный сценарий, чем мой тестовый код. Почему я должен записывать в буферный поток с минимальным количеством вызовов? Если я должен сделать свою собственную буферизацию, какой смысл iostreams в любом случае? А с двоичными данными у меня есть возможность буферизовать их самостоятельно, когда записываешь миллионы чисел в текстовый файл, массового варианта просто не существует, я ДОЛЖЕН вызывать operator <<для каждого.
Бен Фойгт
1
@beldaz: можно оценить, когда I / O начинает доминировать, с помощью простого расчета. При средней скорости записи 90 МБ / с, которая типична для современных жестких дисков потребительского уровня, очистка буфера 4 МБ занимает <45 мс (пропускная способность, задержка не важна из-за кэша записи ОС). Если выполнение внутреннего цикла занимает больше времени, чем заполнение буфера, тогда CPU будет ограничивающим фактором. Если внутренний цикл работает быстрее, то ввод-вывод будет ограничивающим фактором, или, по крайней мере, осталось некоторое время ЦП для выполнения реальной работы.
Бен Фойгт
5
Конечно, это не означает, что использование iostreams обязательно означает медленную программу. Если ввод / вывод является очень малой частью программы, то использование библиотеки ввода / вывода с низкой производительностью не окажет большого общего влияния. Но не частый вызов, чтобы иметь значение, не то же самое, что хорошая производительность, а в тяжелых приложениях ввода / вывода это имеет значение.
Бен Фойгт
27

Я довольно разочарован в пользователях Visual Studio, которые, скорее всего, поделились этим:

  • В визуальной реализации студии ostream, то sentryобъект (который требуется стандарт) входит в критическую секцию , защищающую streambuf(который не требуется). Это не является необязательным, поэтому вы платите за синхронизацию потоков даже для локального потока, используемого одним потоком, который не нуждается в синхронизации.

Это очень больно код, который используется ostringstreamдля форматирования сообщений. Прямое использование stringbufисключает использование sentry, но отформатированные операторы вставки не могут работать напрямую с streambufs. В Visual C ++ 2010 критический раздел замедляется ostringstream::writeв три раза по сравнению с базовым stringbuf::sputnвызовом.

Глядя на данные профилировщика beldaz на newlib , становится ясно, что gcc sentryне делает ничего такого сумасшедшего. ostringstream::writeпод gcc занимает всего около 50% больше, чем сам stringbuf::sputn, но stringbufсам по себе намного медленнее, чем под VC ++. И то, и другое по-прежнему весьма неблагоприятно для использования vector<char>буферизации ввода / вывода, хотя и не с той же разницей, что и в VC ++.

Бен Фойгт
источник
Эта информация все еще актуальна? AFAIK, реализация C ++ 11, поставляемая с GCC, выполняет эту «сумасшедшую» блокировку. Конечно, VS2010 тоже делает это. Может ли кто-нибудь прояснить это поведение, и если «что не требуется» все еще сохраняется в C ++ 11?
mloskot
2
@mloskot: я не вижу требования безопасности потока для sentry... «Класс sentry определяет класс, который отвечает за выполнение операций префикса и суффикса исключительной ситуации ». и примечание «Конструктор и деструктор также могут выполнять дополнительные зависящие от реализации операции». Из принципа C ++ «ты не платишь за то, что не используешь» можно предположить, что комитет C ++ никогда не утвердит такое расточительное требование. Но не стесняйтесь задавать вопрос о безопасности нитей iostream.
Бен Фойгт
8

Проблема, которую вы видите, связана с каждым вызовом метода write (). Каждый добавляемый вами уровень абстракции (char [] -> vector -> string -> ostringstream) добавляет еще несколько вызовов / возвратов функций и другие вспомогательные функции, которые, если вы вызываете это миллион раз, складываются.

Я изменил два примера на ideone, чтобы писать по десять целых за раз. Время остринстрима увеличилось с 53 до 6 мс (почти 10-кратное улучшение), в то время как цикл char улучшился (с 3,7 до 1,5) - полезно, но только в два раза.

Если вы беспокоитесь о производительности, вам нужно выбрать правильный инструмент для работы. ostringstream полезен и гибок, но есть штраф за использование его так, как вы пытаетесь. char [] сложнее, но прирост производительности может быть велик (помните, что gcc, вероятно, также встроит memcpys для вас).

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

Родди
источник
8
Что ostringstream::write()нужно сделать, что vector::push_back()нет? Во всяком случае, это должно быть быстрее, поскольку он передает блок вместо четырех отдельных элементов. Если ostringstreamэто медленнее, чем std::vectorбез предоставления каких-либо дополнительных функций, то да, я бы назвал это сломанным.
Бен Фойгт
1
@Ben Voigt: Наоборот, это то, что вектор должен делать, что не нужно делать острингстриму, что делает вектор более производительным в этом случае. Вектор гарантированно будет непрерывным в памяти, а ostringstream - нет. Vector - это один из классов, предназначенных для повышения производительности, а ostringstream - нет.
Dragontamer5788
2
@Ben Voigt: Использование stringbufнапрямую не приведет к удалению всех вызовов функций, поскольку stringbufоткрытый интерфейс состоит из открытых не виртуальных функций в базовом классе, которые затем отправляются защищенной виртуальной функции в производный класс.
CB Bailey
2
@Charles: на любом приличном компиляторе это должно произойти, поскольку вызов публичной функции будет встроен в контекст, где динамический тип известен компилятору, он может удалить косвенное обращение и даже встроить эти вызовы.
Бен Фойгт
6
@Roddy: Я должен думать, что это весь встроенный код шаблона, видимый в каждом модуле компиляции. Но я думаю, что это может варьироваться в зависимости от реализации. Наверняка я ожидаю, что обсуждаемый вызов, публичная sputnфункция, которая вызывает виртуальный защищенный xsputn, будет встроенным. Даже если xsputnон не встроен, компилятор может во время встраивания sputnопределить точное xsputnнеобходимое переопределение и сгенерировать прямой вызов без прохождения через vtable.
Бен Фойгт
1

Чтобы повысить производительность, вы должны понимать, как работают используемые вами контейнеры. В вашем примере с массивом char [] массив требуемого размера выделяется заранее. В вашем примере с вектором и ostringstream вы заставляете объекты многократно выделять, перераспределять и, возможно, копировать данные много раз по мере роста объекта.

С std :: vector это легко разрешается путем инициализации размера вектора до окончательного размера, как вы делали массив char; вместо этого вы довольно несправедливо ограничиваете производительность, уменьшая размер до нуля! Это вряд ли справедливое сравнение.

Что касается острингстрима, то предварительное распределение пространства невозможно, я бы предположил, что это нецелесообразное использование. Этот класс имеет гораздо большую полезность, чем простой массив char, но если вам не нужна эта утилита, не используйте ее, потому что в любом случае вы заплатите накладные расходы. Вместо этого его следует использовать для того, для чего он хорош - форматирования данных в строку. C ++ предоставляет широкий спектр контейнеров, и ostringstram является одним из наименее подходящих для этой цели.

В случае вектора и ostringstream вы получаете защиту от переполнения буфера, вы не получаете этого с массивом символов, и эта защита не предоставляется бесплатно.

Клиффорд
источник
1
Распределение не является проблемой для ostringstream. Он просто стремится к нулю для последующих итераций. Нет усечения. Также я попробовал, ostringstream.str.reserve(4000000)и это не имело никакого значения.
Родди
Я думаю, с помощью ostringstream, вы могли бы «зарезервировать», передавая фиктивную строку, то есть: с ostringstream str(string(1000000 * sizeof(int), '\0'));помощью vector, resizeне освобождает пространство, оно расширяется, только если это необходимо.
Ним
1
"вектор .. защита от переполнения буфера". Распространенное заблуждение - vector[]по умолчанию оператор НЕ проверяется на наличие ошибок границ. vector.at()однако.
Родди
2
vector<T>::resize(0)обычно не перераспределяет память
Ники Йошиути
2
@Roddy: Не используется operator[], но push_back()(в порядке back_inserter), что определенно проверяет переполнение. Добавлена ​​другая версия, которая не используется push_back.
Бен Фойгт