【问题标题】:Unexpected Huge Memory Consumption for vector of vectors向量的向量意外的巨大内存消耗
【发布时间】:2017-12-30 19:44:02
【问题描述】:

处理具有变长 ASCII 字词的 dict 文件。

constexpr int MAXLINE = 1024 * 1024 * 10; // total number of words, one word per line.

目标:将整个文件读入内存,并能够通过索引访问每个单词。

我想通过索引快速访问每个单词。我们可以使用二维数组来实现;但是需要设置MAXLENGTH,更不用说前面不知道MAXLENGTH

constexpr int MAXLENGTH= 1024; // since I do not have the maximum length of the word
char* aray = new char[MAXLINE * MAXLENGTH];

如果大多数单词都短于MAXLENGTH,上面的代码将不利于记忆;还有一些单词可能比MAXLENGTH长,导致错误。

对于变长对象,我认为vector 可能最适合这个问题。所以我想出了vector的vector来存储它们。

vector<vector<char>> array(MAXLINE);

这看起来很有希望,直到我意识到事实并非如此。

我在一个带有MAXLINE 4-ASCII 字符单词的dict 文件上测试了这两种方法。(这里所有单词都是4-char 单词)

constexpr int MAXLINE = 1024 * 1024 * 10;

如果我新运算符要存储的数组,(这里MAXLENGTH 只是 4)

char* aray = new char[MAXLINE * 4]; 

内存消耗大约为 40MB。但是,如果我尝试使用向量来存储(我将 char 更改为 int32_t 以适合四个字符)

vector<vector<int32_t>> array(MAXLINE);

您也可以使用 char 向量,并为 4 个字符预留空间。

vector<vector<char>> array(MAXLINE);
for (auto & c : array) {
    c.reserve(4);
}

内存消耗猛增到大约 720MB(调试模式),280MB(释放模式),这是出乎意料的高,有人可以给我一些解释以澄清为什么会这样。

观察:向量的大小取决于实现,如果您在调试模式下编译。

在我的系统上

sizeof(vector<int32_t>) = 16  //  debug mode

sizeof(vector<int32_t>) = 12  // release mode

在调试模式下,对于vector&lt;vector&lt;int32_t&gt;&gt; array(MAXLINE);,内存消耗是720MB,而实际向量只需要sizeof(vector&lt;int32_t&gt;) * MAXLINE = 16 * 10MB = 160 MB

在释放模式下,内存消耗为280MB,但预期值为sizeof(vector&lt;int32_t&gt;) * MAXLINE = 12 * 10MB = 120 MB

有人能解释一下实际内存消耗和预期消耗(根据子向量大小计算)的巨大差异吗?

欣赏,新年快乐!

【问题讨论】:

  • 那么您系统上的sizeof(std::vector&lt;std::int32_t&gt;) 是什么?
  • sizeof(std::vector&lt;std::int32_t&gt;) = 16
  • char* aray = new char[MAXLINE * 4];vector&lt;vector&lt;int32_t&gt;&gt; array(MAXLINE); 有何相似之处?他们是不同的,做不同的事情。为什么需要一千万个向量?
  • @n.m.方便访问每个单词
  • 你熟悉XY问题的概念吗?请改写您的问题以解决您面临的实际问题。

标签: c++ arrays vector


【解决方案1】:

对于您的情况:

所以,这是否意味着向量的向量存储小不是一个好主意 对象? ——

一般不会。嵌套的sub-vector 并不是存储大量可变大小序列的好解决方案。例如,您不想使用每个多边形单独的 std::vector 实例来表示允许可变多边形(三角形、四边形、五边形、六边形、n 边形)的索引网格,否则您会倾向于炸毁内存使用并有一个非常慢的解决方案:慢,因为每个单独的多边形都涉及堆分配,并且在内存中爆炸,因为向量除了以通常大于需要的方式存储大小和容量之外,还经常为元素预分配一些内存如果你有一堆很小的序列。

vector 是一种出色的数据结构,可以连续存储一百万个事物,但对于存储一百万个极小的向量来说就不是那么出色了。

在这种情况下,即使是单链索引列表也可以更好地使用指向更大向量的索引,执行速度更快,有时甚至使用更少的内存,尽管有 32 位链接开销,如下所示:

也就是说,对于您的特殊情况,我建议您使用大量可变长度字符串的随机访问序列:

// stores the starting index of each null-terminated string
std::vector<int> string_start;

// stores the characters for *all* the strings in one single vector.
std::vector<char> strings;

这会将开销降低到接近每个字符串条目的 32 位(假设 int 是 32 位),并且您将不再需要为添加的每个字符串条目单独分配堆。

读完所有内容后,您可以通过压缩以截断数组(消除任何多余的保留容量)来最大限度地减少内存使用:

// Compact memory use using copy-and-swap.
vector<int>(string_start).swap(string_start);
vector<char>(strings).swap(strings);

现在要检索第 n 个字符串,您可以这样做:

const char* str = strings.data() + string_start[n];

如果您还需要搜索功能,您实际上可以存储大量字符串并快速搜索它们(包括基于前缀的搜索),存储的内存甚至比使用 compressed trie 的上述解决方案还要少.虽然这是一个涉及更多的解决方案,但如果您的软件围绕字符串字典并搜索它们并且您可能只能找到一些已经为您提供的第三方库,那么它可能是值得的。

std::string

为了完整起见,我想我会提到std::string。最近的实现通常通过预先存储不单独堆分配的缓冲区来优化小字符串。但是,在您的情况下,这可能会导致更大的内存使用量,因为这会使 sizeof(string) 更大,消耗的内存远远超过真正短字符串所需的内存。它确实使std::string 对临时字符串更有用,因此如果您像这样从那个大的字符向量中提取std::string,那么您可能会得到性能非常好的东西:

std::string str = strings.data() + string_start[n];

...而不是:

const char* str = strings.data() + string_start[n];

也就是说,字符的大向量在存储所有字符串时会在性能和内存方面做得更好。一般而言,如果您想存储数百万个小容器,任何类型的通用容器往往都不会表现得如此出色。

主要的概念问题是,当需要一百万个可变大小的序列时,需求的可变大小性质与容器的通用性质相结合将意味着您拥有一百万个很小的内存管理器,所有这些必须潜在地在堆上分配,或者,如果不是,分配比需要更多的数据,以及跟踪其大小/容量(如果它是连续的,等等)。不可避免地,超过 100 万个拥有自己记忆的管理器会变得非常昂贵。

因此,在这些情况下,放弃“完整、独立”容器的便利性而使用一个巨大的缓冲区或一个存储元素数据的巨型容器(如 vector&lt;char&gt; strings 的情况)与另一个容器一起使用通常是值得的索引或指向它的大容器,例如vector&lt;int&gt; string_start。有了它,您可以只使用两个大容器而不是一百万个小容器来表示一百万个可变长度的类比字符串。

删除第 n 个字符串

您的情况听起来并不像您需要删除字符串条目,只需加载和访问,但如果您需要删除字符串,当所有字符串和起始位置的索引都被存储时,这可能会变得很棘手在两个巨大的缓冲区中。

如果你想这样做,我建议不要立即从缓冲区中删除字符串。相反,您可以简单地这样做:

// Indicate that the nth string has been removed.
string_start[n] = -1;

迭代可用字符串时,只需跳过 string_start[n]-1 的字符串。然后,在删除一些字符串后,不时地压缩内存使用,执行以下操作:

void compact_buffers(vector<char>& strings, vector<int>& string_start)
{
    // Create new buffers to hold the new data excluding removed strings.
    vector<char> new_strings;
    vector<int> new_string_start;
    new_strings.reserve(strings.size());
    new_string_start.reserve(string_start.size());

    // Store a write position into the 'new_strings' buffer.
    int write_pos = 0;

    // Copy strings to new buffers, skipping over removed ones.
    for (int start: string_start)
    {
        // If the string has not been removed:
        if (start != -1)
        {
            // Fetch the string from the old buffer.
            const char* str = strings.data() + start;

            // Fetch the size of the string including the null terminator.            
            const size_t len = strlen(str) + 1;

            // Insert the string to the new buffer.
            new_strings.insert(new_strings.end(), str, str + len);

            // Append the current write position to the starting positions
            // of the new strings.
            new_string_start.push_back(write_pos);

            // Increment the write position by the string size.
            write_pos += static_cast<int>(len);
        }
    }

    // Swap compacted new buffers with old ones.
    vector<char>(new_strings).swap(strings);
    vector<int>(new_string_start).swap(string_start);
}

您可以在删除多个字符串后定期调用上述方法来压缩内存使用。

字符串序列

这里有一些代码将所有这些东西放在一起,您可以随意使用和修改。

////////////////////////////////////////////////////////
// StringSequence.hpp:
////////////////////////////////////////////////////////
#ifndef STRING_SEQUENCE_HPP
#define STRING_SEQUENCE_HPP

#include <vector>

/// Stores a sequence of strings.
class StringSequence
{
public:
    /// Creates a new sequence of strings.
    StringSequence();

    /// Inserts a new string to the back of the sequence.
    void insert(const char str[]);

    /// Inserts a new string to the back of the sequence.
    void insert(size_t len, const char str[]);

    /// Removes the nth string.
    void erase(size_t n);

    /// @return The nth string.
    const char* operator[](size_t n) const;

    /// @return The range of indexable strings.
    size_t range() const;

    /// @return True if the nth index is occupied by a string.
    bool occupied(size_t n) const;

    /// Compacts the memory use of the sequence.
    void compact();

    /// Swaps the contents of this sequence with the other.
    void swap(StringSequence& other);

private:
    std::vector<char> buffer;
    std::vector<size_t> start;
    size_t write_pos;
    size_t num_removed;
};

#endif


////////////////////////////////////////////////////////
// StringSequence.cpp:
////////////////////////////////////////////////////////
#include "StringSequence.hpp"
#include <cassert>

StringSequence::StringSequence(): write_pos(1), num_removed(0)
{
    // Reserve the front of the buffer for empty strings.
    // We'll point removed strings here.
    buffer.push_back('\0');
}

void StringSequence::insert(const char str[])
{
    assert(str && "Trying to insert a null string!");
    insert(strlen(str), str);
}

void StringSequence::insert(size_t len, const char str[])
{
    const size_t str_size = len + 1;
    buffer.insert(buffer.end(), str, str + str_size);
    start.push_back(write_pos);
    write_pos += str_size;
}

void StringSequence::erase(size_t n)
{
    assert(occupied(n) && "The nth string has already been removed!");
    start[n] = 0;
    ++num_removed;
}

const char* StringSequence::operator[](size_t n) const
{
    return &buffer[0] + start[n];
}

size_t StringSequence::range() const
{
    return start.size();
}

bool StringSequence::occupied(size_t n) const
{
    return start[n] != 0;
}

void StringSequence::compact()
{
    if (num_removed > 0)
    {
        // Create a new sequence excluding removed strings.
        StringSequence new_seq;
        new_seq.buffer.reserve(buffer.size());
        new_seq.start.reserve(start.size());
        for (size_t j=0; j < range(); ++j)
        {
            const char* str = (*this)[j];
            if (occupied(j))
                new_seq.insert(str);
        }

        // Swap the new sequence with this one.s
        new_seq.swap(*this);
    }

    // Remove excess capacity.
    if (buffer.capacity() > buffer.size())
        std::vector<char>(buffer).swap(buffer);
    if (start.capacity() > start.size())
        std::vector<size_t>(start).swap(start);
}

void StringSequence::swap(StringSequence& other)
{
    buffer.swap(other.buffer);
    start.swap(other.start);
    std::swap(write_pos, other.write_pos);
    std::swap(num_removed, other.num_removed);
}

////////////////////////////////////////////////////////
// Quick demo:
////////////////////////////////////////////////////////
#include "StringSequence.hpp"
#include <iostream>

using namespace std;

int main()
{
    StringSequence seq;
    seq.insert("foo");
    seq.insert("bar");
    seq.insert("baz");
    seq.insert("hello");
    seq.insert("world");
    seq.erase(2);
    seq.erase(3);

    cout << "Before compaction:" << endl;
    for (size_t j=0; j < seq.range(); ++j)
    {
        if (seq.occupied(j))
            cout << j << ": " << seq[j] << endl;
    }
    cout << endl;

    cout << "After compaction:" << endl;
    seq.compact();
    for (size_t j=0; j < seq.range(); ++j)
    {
        if (seq.occupied(j))
            cout << j << ": " << seq[j] << endl;
    }
    cout << endl;
}

输出:

Before compaction:
0: foo
1: bar
4: world

After compaction:
0: foo
1: bar
2: world

我没有费心让它符合标准(太懒了,结果不一定对这种特殊情况有用得多),但希望这里不是一个强烈的需求。

【讨论】:

    【解决方案2】:

    向量的大小取决于实现,如果您在调试模式下编译。通常它至少是一些内部指针的大小(开始、存储结束和保留内存结束)。在我的 Linux 系统上,sizeof(vector&lt;int32_t&gt;) 是 24 字节(每个指针可能是 3 x 8 字节)。这意味着对于您的 10000000 个项目,它至少应该是 ca。 240 MB。

    【讨论】:

    • 在调试模式和发布模式中提及 sizeof 向量的投票可能不同。
    【解决方案3】:

    长度为1vector&lt;uint32_t&gt; 需要多少内存?以下是一些估计:

    1. uint32_t 为 4 个字节。这正是你所期望的。

    2. 约。 8/16 字节动态内存分配开销。人们总是忘记new 实现必须记住分配的大小,以及一些额外的内务管理数据。通常,您可以预期两个指针的开销,因此在 32 位系统上为 8 个字节,在 64 位系统上为 16 个字节。

    3. 约。 4/12 字节用于对齐填充。 any 数据类型的动态分配必须对齐。需要多少取决于 CPU,典型的对齐要求是 8 字节(完全对齐 double)或 16 字节(对于 CPU 向量指令)。因此,您的 new 实现将为 4 字节的有效负载添加 4/12 填充字节。

    4. 约。 vector&lt;&gt; 对象本身的 12/24 字节。 vector&lt;&gt; 对象需要存储三个指针大小的东西:指向动态内存的指针、它的大小和实际使用的对象的数量。乘以指针大小 4/8,得到它的 sizeof。

    总结这一切,我得到了用于存储 4 个字节的 4 + 8/16 + 4/12 + 12/24 = 28/48 字节。


    根据你的数字,我猜你是在 32 位模式下编译的,并且你的最大对齐是 8 个字节。在调试模式下,您的 new 实现似乎增加了额外的分配开销来捕获常见的编程错误。

    【讨论】:

    • 现在更有意义了。谢谢!
    【解决方案4】:

    您正在创建vector&lt;int32_t&gt;41943040 实例,存储在另一个vector 中。我确信 720MB 对于所有实例的内部数据成员加上外部向量的缓冲区来说是一个合理的内存量。

    【讨论】:

    • 那么,这是否意味着向量的向量不是存储小对象的好主意?
    • @phynju:你想存储什么?你真的要存储41943040 向量吗?
    【解决方案5】:

    正如其他人指出的那样,sizeof(vector&lt;int32_t&gt;) 足够大,可以在您初始化 41943040 个实例时产生这样的数字。

    您可能想要的是一个 cpp 字典实现 - 一张地图: https://www.moderncplusplus.com/map/

    它仍然会很大(甚至更大),但在风格上不会那么尴尬。现在,如果内存是一个问题,那就不要使用它。

    sizeof(std::map&lt;std::string, std::string&gt;) == 48 on my system.

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-04-29
      • 2018-07-03
      • 2015-07-04
      • 2022-01-16
      • 2014-01-04
      相关资源
      最近更新 更多