Comments 46
Самописанный умный указатель на атомарном счетчике (лежи в объекте) работает гораздо быстрее чем std::make_shared и кушает меньше памяти…
А в чём, собственно, проблема-то? Так все умные указатели делали, пока авторы shared_ptr не изобрели отдельный control block.
Иными словами зачастую стандартный std::shared_ptr — содержит избыточные функции за которые платим скоростью и памятью…
Давно уже не пишу на С++ и могу ошибаться, но был такой костыль для shared_ptr — базовый класс enable_shared. Не засовывал ли он блок контроля в класс, тем самым превращая всю эту конструкцию в intrusive pointer?
Аж COM пахнуло, AddRef, Release, IUnknown.
Не от хорошей жизни это делали, и не зря перестали.
Вы не занимаетесь ли преждевременной оптимизацией? Ведь надо еще и сложность поддержки учитывать, и общую многословность решения.
У меня есть некоторые сомнения, что правильно используемые shared_ptr хоть сколько-то значимо проиграют COM-like подходу, особенно при современных оптимизациях.
— кастомный аллокатор при загрузке объектов. Никаких счетчиков тут не используется ибо даже они приводят к ощутимым педалям. А так как время жизни четко известно то и удалять все можно одной пачкой.
— те данные что должны получится на выходе (геометрия для прорисовки, высчитанные параметры и прочее) используют счетчики. Тут уже критичен оверхед по памяти. Ибо +-мебагайт памяти на тайл это уже не мало.
Почему не использовать std::unique_ptr? Неужели вам в самом деле надо разделять владение? А если и так — почему надо разделять владения 100 000 объектов, а не одного объекта, содержащего 100 000 элементов?
1) Объект состоит из геометрии (ее не надо хранить всегда, после объединения в батчи выкидывается) и из атрибутов. Атрибутов может быть много они часто повторяются и некоторые нужны после обработки. Сами атрибуты могут быть не большими (особенно числа) и оверхед от shared_ptr существенно увеличит использование памяти.
2) вычисление стиля прорисовки объекта подразумевает много работы над атрибутами. Причём многие действия не создают новые атрибуты а выбирают из уже существующих (например выбрать из возможных локализаций нужный текст или выбрать из набора цветов один нужный). И тут мои замеры показывали что shared_ptr копирует себя медленнее чем вариант без поддержки слабых ссылок. И причина очевидна — shared_prt для копии надо записать 2 указателя и увеличить счётчик, тогда мое самописанное без поддержки слабых ссылок — 1 указатель и увеличить счётчик…
3) Есть набор пользовательских букмарок. Выбрав из всех букмарок те что видны — сделали кластеризацию. Букмарка по сути представляет из себя 2 координаты, ссылку на стиль прорисовки и ещё пару мелких флагов. Поэтому в памяти занимает не много но если для менеджмента использовать shared_ptr возникает очень большой оверхед. Эту самую кластеризацию нужно держать и на случай если надо проверить нажатие и для подготовки прорисовки и для поиска.
Как видите во всех случаях идёт работа с большим набором простых данных. Причём когда объект станет не нужным неизвестно…
Тогда, за счет знания границ пула, первые 16 битов указателя на x64 можно использовать, как счетчик, а следующие — как смещение до следующего свободного/занятого блока для выделения/освобождения за О(1).
Это же позволит избежать вызова апи оси или rtl на этих ваших выделениях по 50000 отдельных объектов на куче.
Можно, конечно, придумать альтернативу: использовать для этих целей вектора объектов вида {указатель, счетчик, смещение-до-следующего-свободного} и дескрипторы (индекс в векторе), но это — не путь самурая, который не боится 100000 shared_ptr-ов…
Особенно весело получилось в триангуляции (когда надо полигон покрыть треугольниками чтоб можно было рисовать на GPU).
Вообще зная некоторые особенности данных (одинаковый размер, одинаковое время жизни, малое количество результатов на выходе итд) можно придумать решение с менеджментом памяти которе будет работать значительно быстрее стандартного. Этим и крут c++.
И да многие при оценке сложности работы алгоритма не учитывают, что работа с памятью не бесплатная…
За конкретной реализацией можно посмотреть boost::intrusive_ptr
. В некоторых случаях достаточно часто используется.
Честно говоря, я очень долгое время думал, что основное назначение std::make_...
— возможность не писать тип в контейнере два раза.
- new Bar;
- foo();
- конструктор std::shared_ptr.
Готовил длинную гневную тираду, потом посмотрел в доки — а оно похоже так и есть. Нет слов, одни выражения...
я бы сказал это особенность которая может не всем подойти.
1. Не везде где есть shared_ptr есть weak_ptr.
2. Выделение одного куска — меньшая фрагментация
3. Выделение одного куска — большая локальность данных (cache friendly).
Ну тут ещё нужно сказать, что два выделения памяти всё же медленнее одного. Как и две деаллокации. Зависит от реализации и состояния кучи, конечно, но как правило заметно. И если weak нет или используются редко, то это ещё один плюс к перечисленным выше. И даже когда weak используется, то часто он ненадолго переживает shared_ptr. Например, при выполнении временной задачи параллельно с разрушением объекта. Или в кэше — до следующей проверки.
Вы же как раз говорите про более редкий сценарий (по крайней мере в моей практике), когда всегда есть weak на жирный объект, который надолго переживает shared_ptr. Ну очень специфическая ситуация.
Если не нравится текущее описание в cpp core guidelines, то туда можно контрибьютить. Можете попытаться там добавить комментарий вида "за исключением ситуаций, когда вы используете долгоживущие weak на толстые объекты и вам не критично замедление, но критична память". Впрочем, сомневаюсь, что примут, т.к. в cpp core guidelines прямым текстом сказано, что из правил всегда есть исключения, а правила предлагают разумные рекомендации для большинства случаев.
Вы раздуваете из мухи слона и предпочитаете преподнести известное многим не как "а я тут узнал!", а как "да оно сломано!". Не сломано, и в документации подробно расписано. Просто документацию читать надо перед тем, как что-то использовать.
Будет ли для вас отрицательная сторона заметна — это вопрос.
Как впрочем и положительная.
Но это совсем не повод утверждать, что отрицательное — это не отрицательное, или что его нет.
Просто документацию читать надо перед тем, как что-то использовать
Статьи на хабре вообще не нужны. Учебники тоже. И комментарии. И обсуждения. И форумы. Это всё для слабаков, которые не умеют читать справочники.
Просто статья получилось очень уж однобокой. Взять мелочь, пропустить всё про частотность сценария, уменьшить влияние двойных аллокаций и деаллокаций на скорость, и объявить всему миру про открытие. Типичная статья в жёлтой прессе.
Я начал с истоков — почему std::make_shared был прямо жизненно необходим. Потому что утечки — это серьёзная проблема. И с утечками sdt::make_shared действительно борется успешно. Или боролся. До c++17. И до c++17 действительно можно было просто применять std::make_shared, даже толком не вникая в подробности. Потому что это решало критичный, первостепенный вопрос. Не читая документации, не разбираясь — просто применять готовый рецепт.
А Вы сейчас — про производительность.
Так вот, производительность — вопрос вторичный. Кому-то может её хватать, а кому-то — нет. Способов оптимизации — масса. Критичность недостаточной производительности как правило намного ниже, чем текущей памяти, которая может заканчиваться крэшами.
К тому же проблема, которая приводила к утечкам, на самом деле является общей — вычисление аргументов функций не определено. И проблема осталась даже в c++17, хотя и исчезла в частных случаях (например, в операторах). Поскольку это общая проблема, про неё нужно знать всем разработчикам на c++.
Про особенности вычисления аргументов тему я тоже поднял. Именно потому, что это важно и в данном контексте, и вообще для разработчика c++.
Это совершенно точно не типичная статья в желтой прессе, ибо ценность информации в типичной статье желтой прессе стремится к 0.
Лично я не знал такие подробности, поэтому данная статья позволит будущему мне сэкономить время (если вдруг придется копать в эту сторону).
Очень важно знать частотность сценариев и влияние на производительность. Я утверждаю, что в большинстве случаев проблемы, обозначенной автором, просто нет. Но есть конкретные ситуации, в которых про особенность нужно помнить. И именно на таких сценариях нужно было сделать акцент. Когда именно возникает необходимость держать weak на тяжёлый объект долго? В статье про это ни слова не сказано.
Я не говорю, что в библиотеках c++ всё идеально. Есть и неудачные решения. Пример того, где на review четыре человека просмотрели ошибку — передача временного объекта в boost::iterator_range. И это именно ошибка дизайна, т.к. тут можно было бы сделать решение, которое было бы безопасным.
Но make_shared — не пример неудачного решения. Это пример продуманного компромисса.
Далее, Вы говорите про производительность.
Да, это важный параметр.
Но:
— std::make_shared не гарантирует повышение производительности. Если бы была функция std::make_single_allocation_shared — вот тогда да, в точку;
— с нехваткой производительности на std::shared_ptr реально столкнуться тогда, когда их в самом деле 100 000. И если бы я на практике такое встретил — выруливал бы не в сторону экономии одной аллокации (всё равно ведь не спасёт), а действительно, как говорит destman, или в сторону кастомного решения со строго необходимым функционалом, или в сторону уменьшения количества разделяемых объектов до единиц — десятков.
Я прикинул — это будет в районе нескольких мс на создание/разрушение на мобилке. Но это суммарно, а оно всё-таки не всё на старте, и потому сильно менее важно. Т.е. да, тезис про производительность не сильно оправдан.
Я потихоньку уменьшаю использование аллокаций и указателей в целом. Но больше эффекта, например, дала замена std::string на string_view во многих местах. Или отказ от хранения json dom в случаях, когда нужен не весь json, а только его подмножество.
При этом при довольно активном использовании shared- и weak-указателей я не помню ситуаций, чтобы «утечка» памяти из-за weak_ptr приводила бы к проблемам, поэтому считаю ваш пример тоже надуманным.
к сожалению, без профилирования нельзя четко сказать: бесполезный и вредный.
- ситуация 1: я шарю int и часто к нему обращаюсь. Очень хорошо, что он в одном блоке с control_block и наплевать, что 4байта памяти будут висеть на слабых ссылках
- ситуация 2: я шарю вектор на 10к элементов и обращаюсь к нему редко. Здесь верно все то, о чем в статье написано
Так что в качестве вывода можно написать, что std::make_shared лучше подходит для малых объектов(до 64 — sizeof(control_block)) и частых обращений. И приводет к подвисанию памяти на больших объектах.
По поводу custom deleter — "не поддерживает" и "не возможно сказать устновлен ли" — это разные характеристики, не правда ли?
По поводу custom new и custom delete — да, но так ли они нужны, когда проще просто залинковать какойнибудь jmalloc?
По поводу custom deleter — «не поддерживает» и «не возможно сказать устновлен ли» — это разные характеристики, не правда ли?
Вот тут Вы не правильно поняли.
В статье я говорю, что невозможно сказать, как именно поведёт себя экземпляр std::shared_ptr, т.е. как он создан — с помощью конструктора или с помощью std::make_shared. Разница в поведении существенная (настолько существенная, что это могли бы быть даже разные статические типы), а выяснить это нельзя.
для примера уйдем в другую крайность, множество маленьких аллокаций и ни одного weak_ptr
#include <iostream>
#include <chrono>
#include <list>
template<class T, class F>
auto test(F&& create)
{
std::list<std::shared_ptr<T>> set;
auto max = std::numeric_limits<size_t>::max();
for (size_t count = 0; count < max - 1; ++count)
{
try
{
set.emplace_back(create(count));
}
catch (const std::bad_alloc&)
{
return count;
}
}
return max;
}
int main(int argc, char *argv[])
{
using namespace std::chrono;
using type = int;
if (argc == 1)
{
auto start = steady_clock::now();
auto val = test<type>([](auto val) { return std::make_shared<type>(val); });
auto end = steady_clock::now();
std::cout << "make_shared | count: " << val << ", time: " << duration_cast<milliseconds>(end - start).count() << "msc\n";
}
else
{
auto start = steady_clock::now();
auto val = test<type>([](auto val) { return new type(val); });
auto end = steady_clock::now();
std::cout << "new | count: " << val << ", time: " << duration_cast<milliseconds>(end - start).count() << "msc\n";
}
}
вариант с make_shared делает 2 аллокации на элемент, вместо трех, позволяя эффективнее использовать память
на моем компе x86 вариант с make_shared размещает на треть элементов больше)
PS. тестить лучше в разные запуски
Ох уж этот std::make_shared…