Std:: map в классе: компромисс между скоростью выполнения и использованием памяти

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

Итак, у меня есть класс, содержащий кучу числовых свойств (хранящихся в int и double). Простым примером может быть

class MyObject
{
  public:
    double property1;
    double property2;
    ...
    double property14
    int property15;
    int property16;
    ...
    int property25;
    MyObject();
    ~MyObject();
};

Этот класс используется различными программами, которые создают экземпляр

std::vector<MyObject> SetOfMyObjects;

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

Поэтому я думал об использовании std:: maps для назначения памяти только для свойств, которые я использую. Например

class MyObject
{
  public:
    std::map<std::string, double> properties_double;
    std::map<std::string, int> properties_int;
    MyObject();
    ~MyObject();
};

чтобы при вычислении "property1" он сохранялся как

MyObject myobject;
myobject.properties_double["property1"] = the_value;

Очевидно, я бы определил правильные методы "set" и "get".

Я понимаю, что доступ к элементам в std:: map идет как логарифм его размера, но поскольку количество свойств довольно мало (около 25), я полагаю, что это также не должно замедлять выполнение кода много.

Я слишком сильно задумываюсь над этим? Считаете ли вы, что использование std:: map - хорошая идея? Любое предложение от более опытных программистов будет оценено.

Ответ 1

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

Тогда возникает проблема, что int обычно вдвое меньше double. И они разные. Таким образом, невозможно хранить оба в одном контейнере, но у вас может быть достаточно места для double в каждом элементе и либо использовать union, либо просто читать/писать a int с/на адрес double, если свойство "index" больше 14.

Итак, у вас может быть что-то простое:

struct Property {
   int type;
   union {
       int d_int;
       double d_double;
   };
};

class MyObject {
    std::vector<Property> properties;
};

И для type 1 - 14 вы читаете поле d_double, для type 15 - 25 поля d_int.

ОРИЕНТИРЫ!!!

Из любопытства я провел некоторое тестирование, создав 250 тыс. объектов, каждый из которых имеет 5 внутренних и 5 двойных свойств, используя вектор, карту и хэш для свойств, а также измеренное использование памяти и время, затраченное на установку и получение свойств, выполнял каждый тест 3 раза подряд, чтобы увидеть влияние на кеширование, вычислить контрольную сумму для геттеров, чтобы проверить согласованность, и вот результаты:

vector | iteration | memory usage MB | time msec | checksum 
setting 0 32 54
setting 1 32 13
setting 2 32 13
getting 0 32 77 3750000
getting 1 32 77 3750000
getting 2 32 77 3750000

map | iteration | memory usage MB | time msec | checksum 
setting 0 132 872
setting 1 132 800
setting 2 132 800
getting 0 132 800 3750000
getting 1 132 799 3750000
getting 2 132 799 3750000

hash | iteration | memory usage MB | time msec | checksum 
setting 0 155 797
setting 1 155 702
setting 2 155 702
getting 0 155 705 3750000
getting 1 155 705 3750000
getting 2 155 706 3750000

Как и ожидалось, векторное решение на сегодняшний день является самым быстрым и эффективным, хотя на него больше всего влияет холодный кэш, даже если он работает холодно, он быстрее, чем реализация карты или хэша.

При холодном прогоне реализация вектора в 16.15 раз быстрее, чем карта, и в 14.75 раз быстрее, чем хеш. В теплом беге он еще быстрее - в 61 раз быстрее и в 54 раза быстрее.

Как и для использования памяти, векторное решение намного более эффективно, используя в 4 раза меньше памяти, чем решение карты, и почти в 5 раз меньше, чем решение хэша.

Как я уже сказал, это немного лучше.

Чтобы уточнить, "холодный прогон" - это не только первый запуск, но и тот, который вставляет фактические значения в свойствах, поэтому он достаточно иллюстративен для служебных данных операций вставки. Ни один из контейнеров не использовал preallocation, поэтому они использовали свои политики расширения по умолчанию. Что касается использования памяти, возможно, он точно не отражает фактическое использование памяти на 100% точно, так как я использую весь рабочий набор для исполняемого файла, и обычно обычно выполняется предварительное распределение на уровне ОС, оно будет наиболее вероятно, будет более консервативным по мере увеличения рабочего набора. И последнее, но не менее важное: решения карты и хэша реализованы с использованием поиска строк, как первоначально предполагалось ОП, поэтому они настолько неэффективны. Использование целых чисел в качестве ключей на карте и хеш дает гораздо более конкурентные результаты:

vector | iteration | memory usage MB | time msec | checksum 
setting 0 32 55
setting 1 32 13
setting 2 32 13
getting 0 32 77 3750000
getting 1 32 77 3750000
getting 2 32 77 3750000

map | iteration | memory usage MB | time msec | checksum 
setting 0 47 95
setting 1 47 11
setting 2 47 11
getting 0 47 12 3750000
getting 1 47 12 3750000
getting 2 47 12 3750000

hash | iteration | memory usage MB | time msec | checksum 
setting 0 68 98
setting 1 68 19
setting 2 68 19
getting 0 68 21 3750000
getting 1 68 21 3750000
getting 2 68 21 3750000

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

Что касается того, сколько памяти сохранено по сравнению с наличием всех свойств в качестве членов объекта, просто грубым вычислением потребуется около 80 МБ ОЗУ, чтобы иметь 250 КБ таких объектов в последовательном контейнере. Таким образом, вы сохраняете 50 МБ для векторного решения и почти ничего для хэш-решения. И само собой разумеется - прямой доступ к членству будет намного быстрее.

Ответ 2

TL; DR: это не стоит.

От плотников мы получаем: измерять дважды, вырезать один раз. Примените его.


Ваши 25 int и double будут занимать процессор x86_64:

  • 14 double: 112 байт (14 * 8)
  • 11 int: 44 байта (11 * 4)

в общей сложности 156 байт.


A std::pair<std::string, double> будет, по большей части реализации, потреблять:

  • 24 байта для строки
  • 8 байтов для двойного

и node в std::map<std::string, double> добавят не менее 3 указателей (1 родительский, 2 ребенка) и флаг "красный-черный" для еще 24 байтов.

Это не менее 56 байт на каждое свойство.

Даже с распределителем 0-накладных расходов, каждый раз, когда вы храните 3 элемента или более в этом map, вы используете более 156 байт...


Сжатая (тип, свойство) пара будет занимать:

  • 8 байтов для свойства (double - наихудший случай)
  • 8 байтов для типа (вы можете выбрать меньший тип, но выравнивание в нем)

в общей сложности 16 байт на пару. Гораздо лучше, чем map.

Сохраняется в vector, это будет означать:

  • 24 байта служебных данных для vector
  • 16 байт на каждое свойство

Даже с распределителем 0-накладных расходов, каждый раз, когда вы храните 9 элементов или более в этом vector, вы используете более 156 байт.


Вы знаете решение: разделите этот объект.

Ответ 3

Вы ищете объекты по имени, которые, как вы знаете, будут там. Посмотрите их по имени.

Я понимаю, что доступ к элементам в std:: map идет как логарифм его размера, но поскольку количество свойств довольно мало (около 25), я полагаю, что это также не должно замедлять выполнение кода много.

Вы замедляете свою программу более чем на один порядок. Поиск карты может быть O (logN), но O (LogN) * ​​C. C будет огромным по сравнению с прямым доступом к свойствам (в тысячи раз медленнее).

подразумевает, что память для миллионов бесполезных int и double выделена

A std::string составляет не менее 24 байтов во всех реализациях, о которых я могу думать, - если вы хотите, чтобы имена свойств были короткими (оптимизация коротких строк в google) для деталей).

Если 60% ваших свойств не заселены, сохранение не происходит с помощью карты, в которую вставляется строка.

Ответ 4

С таким количеством объектов и маленьким объектом карты в каждом случае вы можете столкнуться с другой проблемой - фрагментацией памяти. Можно использовать std::vector с std::pair<key,value> в нем вместо этого и искать (я думаю, что бинарный поиск должен быть достаточным, но это зависит от вашей ситуации, было бы дешевле сделать линейный поиск, но не сортировать вектор), Для ключа свойства я бы использовал перечисление вместо строки, если позже не продиктовано интерфейсом (который вы не показывали).

Ответ 5

Просто идея (не скомпилирована/протестирована):

struct property_type
{
  enum { kind_int, kind_double } k;
  union { int i; double d; };
};

enum prop : unsigned char { height, widht, };

typedef std::map< std::pair< int/*data index*/, prop/*property index*/ >, property_type > map_type;

class data_type
{
  map_type m;

public:

  double& get_double( int i, prop p )
  {
    // invariants...
    return m[ std::pair<int,prop>(i,p) ].d; 
  }

};

Ответ 6

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

Маршрут карты выглядит так, будто это будет пустой тратой времени, но есть альтернатива, которую вы можете использовать, которая сохраняет память, сохраняя при этом достойные характеристики производительности: сохраняйте детали в отдельном векторе и храните индекс в этот вектор (или -1 для неназначенных) в вашем основном типе данных. К сожалению, ваше описание на самом деле не указывает, как выглядит свойство использования, но я собираюсь предположить, что вы можете подразделять на свойства, которые всегда или обычно устанавливаются вместе, и некоторые, которые необходимы для каждого node. Скажем, вы подразделите на четыре набора: A, B, C и D. As необходимо для каждого node, тогда как B, C и D редко задаются, но все элементы обычно изменяются вместе, а затем изменяют структуру, которую вы храните так:

struct myData {
  int A1;
  double A2;
  int B_lookup = -1;
  int C_lookup = -1;
  int D_lookup = -1;
};

struct myData_B {
  int B1;
  double B2;
  //etc.
};

// and for C and D

а затем сохраните 4 вектора в вашем основном классе. Когда свойство в Bs, к которому вы обращались, добавляет новый вектор myData_B к вектору Bs (на самом деле дека может быть лучшим выбором, сохраняя быстрый доступ, но без одинаковых проблем фрагментации памяти) и установите значение B_lookup в оригинал myData к индексу нового myData_B. И то же самое для Cs и Ds.

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