为什么 GCC 的 ifstream >> double 分配这么多内存?

Why does GCC's ifstream >> double allocate so much memory?

我需要从 a space-separated human-readable file 中读取一系列数字并做一些数学运算,但我 运行 只是在读取文件时遇到了一些真正奇怪的内存行为。

如果我读到这些数字并立即丢弃它们...

#include <fstream>

int main(int, char**) {
    std::ifstream ww15mgh("ww15mgh.grd");
    double value;
    while (ww15mgh >> value);
    return 0;
}

我的程序根据 valgrind 分配了 59MB 的内存,相对于文件的大小线性缩放:

$ g++ Whosebug.cpp
$ valgrind --tool=memcheck --leak-check=yes ./a.out 2>&1 | grep total
==523661==   total heap usage: 1,038,970 allocs, 1,038,970 frees, 59,302,487 

但是,如果我改用 ifstream >> string 然后使用 sscanf 来解析字符串,我的内存使用情况看起来会更合理:

#include <fstream>
#include <string>
#include <cstdio>

int main(int, char**) {
    std::ifstream ww15mgh("ww15mgh.grd");
    double value;
    std::string text;
    while (ww15mgh >> text)
        std::sscanf(text.c_str(), "%lf", &value);
    return 0;
}
$ g++ Whosebug2.cpp
$ valgrind --tool=memcheck --leak-check=yes ./a.out 2>&1 | grep total
==534531==   total heap usage: 3 allocs, 3 frees, 81,368 bytes allocated

为了排除 IO 缓冲区的问题,我尝试了 ww15mgh.rdbuf()->pubsetbuf(0, 0);(这使得程序需要很长时间,但仍然进行了 59MB 的分配)和 pubsetbuf 以及巨大的堆栈-分配的缓冲区(仍然是 59MB)。在 gcc 10.2.0 and clang 11.0.1 when using /usr/lib/libstdc++.so.6 from gcc-libs 10.2.0 and /usr/lib/libc.so.6 from glibc 2.32 上编译时会重现该行为。系统区域设置为 en_US.UTF-8 但如果我设置环境变量 LC_ALL=C.

也会重现

我首先注意到问题的 ARM CI 环境是在 Ubuntu Focal 上使用 GCC 9.3.0, libstdc++6 10.2.0 and libc 2.31.

交叉编译的

之后,我尝试了 LLVM 的 libc++ 并在原始程序中获得了完全正常的行为:

$ clang++ -std=c++14 -stdlib=libc++ -I/usr/include/c++/v1 Whosebug.cpp
$ valgrind --tool=memcheck --leak-check=yes ./a.out 2>&1 | grep total
==700627==   total heap usage: 3 allocs, 3 frees, 8,664 bytes allocated

因此,这种行为似乎是 GCC 实现 fstream 所特有的。在构建或使用 ifstream 时,我是否可以做一些不同的事情来避免在 GNU 环境中编译时分配大量堆内存?这是他们 <fstream> 中的错误吗?

正如在评论讨论中发现的那样,该程序的实际内存占用量非常合理 (84kb),它只是分配和释放相同的少量内存数十万次,这在使用自定义分配器时会产生问题像避免重复使用堆的 ASAN space。我发帖a follow-up question询问如何在“ASAN”级别处理此类问题。

一个gitlab project that reproduces the issue in its CI pipeline was generously contributed by Stack Overflow user @KamilCuk.

不幸的是,基于 C++ 流的 I/O 库通常没有得到充分利用,因为每个人都“知道”它性能不佳,所以这里存在先有鸡还是先有蛋的问题 - 不好的意见导致很少使用导致稀疏的错误报告导致修复压力小。

我想说 C++ 流的最大用户是基础 CS/IT 教育部门和“快速一次性脚本”(它总是比作者长寿),没有人真正关心性能.

您所看到的只是一个浪费的实现 - 它不断地在内部某处分配和解除分配,但据我所知它不会泄漏内存。我不认为有任何类型的“模式”可以在使用流 I/O.

时以非脆弱的方式保证更好的性能

在嵌入式环境中赢得此胜利的最佳策略是根本不玩游戏。忘掉 C++ 流 I/O,一切都会好起来的。有替代格式的 I/O 库可以恢复 C++ 的类型安全并且性能更好,然后您就不会受制于标准库实现 bugs/inefficiencies。或者,如果您不想添加依赖项,则只使用 sscanf

真的没有。 valgrind显示的数字59,302,487是所有分配的总和,并不代表程序的实际内存消耗。

原来相关operator>>的libstdc++实现为scratchspace创建了一个临时的std::string,并为其预留了32字节。然后在使用后立即释放。参见 num_get::do_get。考虑到开销,这可能实际上分配了 56 个字节左右,乘以大约 100 万次重复确实意味着,在某种意义上,总共分配了 59 兆字节,当然这就是为什么这个数字与输入数量成线性比例关系.但它是相同的 56 个字节,一遍又一遍地分配和释放。这是 libstdc++ 完全无辜的行为,不是泄漏或过度内存消耗。

我没有检查 libc++ 源代码,但一个不错的选择是它在堆栈而不是堆上使用 scratch space。

正如评论中所确定的,您真正的问题是您 运行 在 AddressSanitizer 下,它延迟了已释放内存的重用,以帮助捕获释放后使用错误。我有一些关于如何解决这个问题的想法(没有双关语意)并将 post 他们 How do I exclude allocations in a tight loop from ASAN?