LINUX.ORG.RU

Передача в функцию тяжёлого temporary

 


0

9

Допустим, у меня есть следующий код:

#include <string>
#include <vector>

void foo(const std::vector<std::string> &vec)
{
    /* произвольные операции с vec */
}

int main(int, char **)
{
    for (unsigned i = 0; i < 1e6; ++i) {
        foo({"foo" /* много букв */,
             "bar" /* много букв */,
             "baz" /* много букв */});
    }
}

Под «много букв» понимаются строки, достаточно длинные для того, чтобы исключить small string optimization.

При выполнении данной программы я наблюдаю (clang 3.9.0, gcc 6.2.1, -std=с++14, наблюдения произведены с помощью valgrind), что вне зависимости от уровня оптимизации происходит 7 * 1e6 выделений памяти.

Отсюда возникает два вопроса:

  1. Почему аллокаций 7 миллионов, а не 4, учитывая move semantics и всё такое?
  2. Почему компилятор не может построить вектор со строками статически в .rodata, учитывая, что он передаётся по константной ссылке и не изменяется? Или какие-то свойства языка запрещают так делать?

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

★★★★★

Последнее исправление: intelfx (всего исправлений: 4)

1. Почему аллокаций 7 миллионов, а не 4, учитывая move semantics и всё такое?

GCC 5.3.0

==13237==   total heap usage: 4,000,001 allocs, 4,000,000 frees, 108,072,704 bytes allocated

move тут негде происходить.

2. Почему компилятор не может построить вектор со строками статически в .rodata, учитывая, что он передаётся по константной ссылке и не изменяется? Или какие-то свойства языка запрещают так делать?

Было бы оно constexpr, может компилятор так и сделал. А так у конструкторов/деструкторов могут быть побочные эффекты (то же выделение/освобождение памяти в хипе) и это невозможно. Из-за этих же эффектов оно не может автоматически быть вынесено за пределы цикла.

xaizek ★★★★★
()
Ответ на: комментарий от xaizek

Наличие побочных эффектов в copy- и move-конструкторах не мешает делать copy elision.

anonymous
()
Ответ на: комментарий от xaizek

GCC 5.3.0
==13237== total heap usage: 4,000,001 allocs, 4,000,000 frees, 108,072,704 bytes allocated

Регрессия? Не верится.

move тут негде происходить.

Ну как же «негде»? Из initializer_list'а.

Было бы оно constexpr, может компилятор так и сделал. А так у конструкторов/деструкторов могут быть побочные эффекты (то же выделение/освобождение памяти в хипе) и это невозможно. Из-за этих же эффектов оно не может автоматически быть вынесено за пределы цикла.

Т. е. правило «as-if» распространяется и на такие низкоуровневые побочные эффекты, как количество и порядок вызовов аллокатора?

intelfx ★★★★★
() автор топика
Ответ на: комментарий от xaizek

total heap usage: 4,000,001 allocs, 4,000,000 frees

Что-то странное у вас двоих происходит, у меня:

total heap usage: 1,000,001 allocs, 1,000,001 frees

Что логично, т.к. такие короткие строки не хранятся в куче, они создаются в хвосте самого объекта std::string.

П.С. gcc 6.2

anonymous
()

чтобы исключить small string optimization

Туплю.

anonymous
()
Ответ на: комментарий от intelfx

(три строки + один вектор) * 1М раз.

С каким-нибудь array_view<string_view> может и аллокаций не будет. А вообще из цикла такое надо выносить, при возможности.

xaizek ★★★★★
()
Ответ на: комментарий от xaizek

Да я понял, что три строки + один вектор, но анонимус выше корректно говорит, что initializer_list указывает на константный массив объектов (а конструктор вектора принимает конкретно initializer_list<T>, а не любого типа, приводимого к T), поэтому по стандарту без копирования строк вроде как не обойтись. Отсюда вопрос: как у тебя получилось это обойти?

intelfx ★★★★★
() автор топика
Последнее исправление: intelfx (всего исправлений: 2)
Ответ на: комментарий от xaizek

А вообще из цикла такое надо выносить, при возможности.

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

intelfx ★★★★★
() автор топика
Последнее исправление: intelfx (всего исправлений: 1)
Ответ на: комментарий от DELIRIUM

std::vector::reserve(), не?

Вообще не в ту степь. У меня вопрос именно про temporary. Так-то, понятно, я могу вынести этот вектор в статическую константную переменную и всё решится, но от этого пострадает читабельность.

А llvm/clang из транка такой же результат дают?

Выше уже объяснили, что это по стандарту так.

intelfx ★★★★★
() автор топика
Последнее исправление: intelfx (всего исправлений: 1)
Ответ на: комментарий от intelfx

как у тебя получилось это обойти?

Судя по всему, GCC на стеке создал три строки объединённые в initializer_list и из него сконструировал вектор 1М раз. Возможно, из-за отсутствия передачи в функцию он проанализировал использование и решил, что можно и так. Могли исправить в GCC6, ибо это как-то непоследовательно.

xaizek ★★★★★
()
Последнее исправление: xaizek (всего исправлений: 1)
Ответ на: комментарий от intelfx

Т. е. правило «as-if» распространяется и на такие низкоуровневые побочные эффекты, как количество и порядок вызовов аллокатора?

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

На побочные эффекты copy ellision может закрыть глаза:

When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class
object, even if the constructor selected for the copy/move operation and/or the destructor for the object
have side effects.
Может в моём случае это работает (передача в качестве аргумента по ссылке, мне кажется, предотвращает эту оптимизацию):
— when a temporary class object that has not been bound to a reference (12.2) would be copied/moved
to a class object with the same cv-unqualified type, the copy/move operation can be omitted by
constructing the temporary object directly into the target of the omitted copy/move

xaizek ★★★★★
()
Последнее исправление: xaizek (всего исправлений: 1)

gcc (GCC) 6.2.1 20160830

total heap usage: 7,000,001 allocs, 7,000,001 frees, 1,128,072,704 bytes allocated

void foo(const std::vector<std::string>& vec) {
}

int main() {
	for (unsigned i = 0; i < 1e6; ++i) {
		std::vector<std::string> v;
		
		foo({"foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofooofoooofoooofoooofoooofooooofoo",
		     "bbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarrbararrbararrbararrbararbarar",
		     "bbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazaz"});
	}

	return 0;
}

total heap usage: 4,000,001 allocs, 4,000,001 frees, 612,072,704 bytes allocated

void foo(const std::vector<std::string>& vec) {
}

int main() {
	for (unsigned i = 0; i < 1e6; ++i) {
		std::vector<std::string> v;
		v.reserve(3);
		v.push_back("foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofooofoooofoooofoooofoooofooooofoo");
		v.push_back("bbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarrbararrbararrbararrbararbarar");
		v.push_back("bbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazaz");
		
		foo(std::move(v));
	}

	return 0;
}

total heap usage: 1,000,001 allocs, 1,000,001 frees, 48,072,704 bytes allocated

void foo(const std::vector<boost::string_view>& vec) {
}

int main() {
	for (unsigned i = 0; i < 1e6; ++i) {
		std::vector<boost::string_view> v;
		v.reserve(3);
		v.push_back("foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofooofoooofoooofoooofoooofooooofoo");
		v.push_back("bbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarrbararrbararrbararrbararbarar");
		v.push_back("bbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazbazaz");
		
		foo(std::move(v));
	}

	return 0;
}

Инит лист, энивей, копируется. Если в последнем коде его заюзать будет 2 миллиона аллокаций. Самая плохая фича 11 стандарта, еще и uniform initialization ломает.

Kuzy ★★★
()
Ответ на: комментарий от intelfx

Очевидное решение в том, чтобы вынести этот вектор в статическую константную переменную, но хочется всё-таки не жертвовать читабельностью

Вот этот момент мне непонятен. Ты выносишь инициализацию вектора в отдельную переменную, даешь ей говорящее название. Как это может быть менее понятно, чем существующий вариант. Если понадобиться, все места, в которых эта переменная используется, можно посмотреть в IDE по «Find Usages». Поясни, почему это станет менее читаемо...

chimera19
()
Ответ на: комментарий от Kuzy

4.8.1 - 4M аллокаций
как в 6.2.1 - 7М получилось ?

x905 ★★★★★
()

а еще 1 выделение для for ?

x905 ★★★★★
()
Ответ на: комментарий от intelfx

У меня вопрос именно про temporary.

ну так конструкция вектора, деструкция, конструкция каждой строки, деструкция. отсюда и выделения.

invy ★★★★★
()
Ответ на: комментарий от invy

Я в курсе.

ну так конструкция вектора, деструкция, конструкция каждой строки, деструкция. отсюда и выделения.

Вопрос в том, можно ли заставить компилятор построить вектор единожды (статически), но чтобы это синтаксически красиво выглядело (т. е. без временных переменных). Видимо, нельзя.

intelfx ★★★★★
() автор топика
Ответ на: комментарий от invy

Ага, я по привычке написал, думая, что там передача по значению.

Из-за этого не бесполезный, кстати. Можно сигнатуру функции менять и тебе за это ничего не будет.

Kuzy ★★★
()
Ответ на: комментарий от intelfx

В лямбду оберни. Лишние переменные будут, но в другом скоупе.

Kuzy ★★★
()

Обалдеть :-) Цепепе настолько гибок, что вынуждает его любителей писать тесты, для понимания что же быстрее, описанный в современном стандарте initializer_list или давний push_back :-) Хе-хе-хе :-)

anonymous
()

Почему компилятор не может построить вектор со строками статически в .rodata, учитывая, что он передаётся по константной ссылке и не изменяется? Или какие-то свойства языка запрещают так делать?

Всё просто - на уровне компилятора такого языка как С++ не существует - он существует только на уровне рантайма, вернее что-то и существует, но не то, что ты юзаешь.

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

ftrspnsp
()
Ответ на: комментарий от ftrspnsp

Да, можно научить компилятор понимать рантайм конструкции

Я ожидал, что это уже в каком-то виде сделано. Если такого нигде нет — окей, вопрос исчерпан.

intelfx ★★★★★
() автор топика
Последнее исправление: intelfx (всего исправлений: 2)
Ответ на: комментарий от intelfx

Я ожидал, что это уже в каком-то виде сделано.

А как ты себе это представляешь? Как можно выпилить вызов аллокатора, который можно переопределить в другой единицы трансляции? Никак. Разве что на уровне lto и то libc++ надо собирать с лто и статически линковать.

ftrspnsp
()
Ответ на: комментарий от ftrspnsp

А как ты себе это представляешь?

Примерно так же, как вызов memcpy(dest, src, sizeof void *) заменяется на два mov.

intelfx ★★★★★
() автор топика
Последнее исправление: intelfx (всего исправлений: 1)
Ответ на: комментарий от intelfx

Ну во-первых совершенно разные ситуации. В одном мы просто инлайним функцию( это не понимание логики рантайма - это просто кастыль из-за ущербанской допотопной системы раздельной сборки, линковки и прочего днища из 50-х годов. С таким же успехом можно просто определить тело memcpy() в единице трансляции - будет тот же самый эффект), а во второй какая-то совершенно левая логика в которой мы могли бы работать с памятью как-то на уровне компиляции.

В сишке единственная память в которой могут создаваться объекты на уровне компиляции - это списки инициализации. На этом всё. Собственно и в С++ это так.

Я думаю, что году к 2030 крестовики дойдут до понятия «компилтайм память», а их язык не будет из себя представлять протухшую сишку из 50-х годов. Раньше чего-то ждать не следует.

Тем более, насколько я знаю, переопределение operator new[]() определено на уровне стандарта крестов, а переопределение malloc() нет. Компиляторы спокойно выпиливают не имеющие эффекта вызовы malloc(), но опять же - только выпиливают.

ftrspnsp
()
Ответ на: комментарий от ftrspnsp

ущербанской
днища
протухшую сишку

Nick: ftrspnsp
Дата регистрации: 29.11.2016 15:33:46
Последнее посещение: 29.11.2016 18:16:23

Царь, што ль, вернулся?

eao197 ★★★★★
()
Ответ на: комментарий от ftrspnsp

Ну во-первых совершенно разные ситуации.

Что значит «совершенно разные»? В одном случае компилятор делает некие предположения насчёт поведения функции memcpy(), а именно — что в список её побочных эффектов не входит скачивание двух гигабайт прона тебе в ~/Downloads, следовательно, её можно заменить builtin'ом и развернуть в две инструкции.

А раз так, то компилятор мог бы сделать аналогичные предположения насчёт поведения своего стандартного аллокатора и заменить его вызов на подстановку заранее выделенного адреса в .rodata.

Вот мой ход рассуждений. Дальше ставится вопрос: этот ход рассуждений где-то ошибочен, или же подобная оптимизация попросту «не реализована»?

intelfx ★★★★★
() автор топика
Последнее исправление: intelfx (всего исправлений: 1)
Ответ на: комментарий от intelfx

В одном случае компилятор делает некие предположения насчёт поведения функции memcpy()

Не обязательно. Это может быть простое встраивание по общим правилам с последующей раскруткой цикла.

Esper
()
Ответ на: комментарий от Esper

Где-то — может быть, но у себя в /usr/include я сишного кода memcpy() не наблюдаю.

intelfx ★★★★★
() автор топика
std::vector<std::string>& vector_instance() {
  static std::vector<std::string> vec;
  return vec;
}


template <typename ...Ts>
const std::vector<std::string>& make_vector( Ts && ...ts ) {
  auto &vec = vector_instance();
  return vec.empty() ? (vec = std::vector<std::string>{ std::forward<Ts>(ts)... }) : vec;
}


int main(int, char **) {

  for (unsigned i = 0; i < 1e6; ++i) {
    foo( make_vector( 
      "foo" /* много букв */,
      "bar" /* много букв */,
      "baz" /* много букв */ ) );
  }
}
Euphorian
()
Ответ на: комментарий от Euphorian

Единственный дельный ответ. Спасибо. Разве что я бы тогда сделал так:

template <int D, typename T, typename ...Ts>
const std::vector<T>& make_vector( Ts && ...ts ) {
  static std::vector<T> vec(std::forward<Ts>(ts)...);
  return vec;
}

D — различитель на случай многократного использования с одними и теми же типами (в этом случае нужны, разумеется, разные векторы).

intelfx ★★★★★
() автор топика
Ответ на: комментарий от Esper

А замена memmove на memcpy - это не понимание рантайм конструкций?

Это не имеет смысла. Нет.

Замена «шило» на «мыло» в тексте является понимаем конструкций? Нет. Это тупая замена и ничего более.

ftrspnsp
()
Ответ на: комментарий от anonymous

Затем, что в его решении я не могу позвать эту функцию из двух разных мест и получить два разных (но статически сконструированных) вектора.

intelfx ★★★★★
() автор топика
Ответ на: комментарий от intelfx

Типа int спасёт тебя, лол. Кто-нибудь возьмёт и пед^Hредаст одинаковый int из нескольких мест.

anonymous
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.