【问题标题】:Do memory allocation functions indicate that the memory content is no longer used?内存分配函数是否表明不再使用内存内容?
【发布时间】:2016-03-06 10:17:17
【问题描述】:

在处理某些数据流时,例如来自网络的请求,使用一些临时内存是很常见的。例如,一个 URL 可能被拆分为多个字符串,每个字符串都可能从堆中分配内存。这些实体的使用通常是短暂的,并且内存总量通常相对较小,应该适合 CPU 缓存。

在用于临时字符串的内存被释放时,字符串内容很可能只存在于缓存中。但是,CPU 不知道内存正在被释放:释放只是内存管理系统中的更新。结果,当 CPU 高速缓存用于其他内存时,CPU 可能最终将未使用的内容不必要地写入实际内存 - 除非内存释放以某种方式向 CPU 表明不再使用内存。因此,问题变成了:

释放内存的内存管理函数是否以某种方式表明相应内存的内容可以被丢弃? 有没有办法向 CPU 指示不再使用内存? (至少,对于某些 CPU:显然,架构之间可能存在差异)由于不同的实现可能会在质量上有所不同,并且可能会或可能不会做任何花哨的事情,问题真的是是否有 any内存管理实现将内存指示为未使用?

我确实意识到始终使用相同的内存区域可能是一种缓解策略,以避免对实际内存进行不必要的写入。在这种情况下,将使用相同的缓存内存。类似地,内存分配可能总是产生相同的内存,同时也避免了不必要的内存传输。但是,可能我不需要依赖任何适用的技术。

【问题讨论】:

  • 一般来说,这是实现定义的。您能否将这个问题缩小到一种编程语言(C/C++ 不是一种编程语言)和可能的特定实现?否则我们很快就会到达“太宽泛”的领域。
  • @FUZxxl:Kühl 先生很清楚这些差异,我可以向你保证。但是 CPU 不知道它是在执行 free() 还是 delete,所以我们可以放心地忽略这些差异。
  • @FUZxxl:所以?问题是关于这些函数的实现。无论如何,这本质上是不可移植的代码,实际上问题在于 C 和 C++ 内存分配器对 CPU 缓存控制指令的使用。
  • @FUZxxl:language-agnostic 的问题是这个问题对 95% 的语言没有意义。而且我们没有“编译为本机应用程序的高性能语言,具有确定性显式内存释放”的标签。
  • @FUZxxl 对于许多主题,C 和 C++ 语言的许多问题都可以得到相同的回答;如果他们不能,那么区分这种区别是有用的。这样做的答案更丰富,因此我不会分享您对标记 [c] 和 [c++] 的反感。 OP 的问题是 a priori 特别容易有一个共同的答案,因为glibc 不太可能使用libstdc++ 没有的缓存技巧。顺便说一句,大多数语言的运行时/解释器都以某种方式基于 C 库之上!我也分享了 OP 对曝光不足的担忧,尽管获得了最大赏金,但仍有 88 次观看。

标签: performance memory memory-management dynamic-memory-allocation cpu-cache


【解决方案1】:

没有。

您提到的缓存操作(将缓存的内存标记为未使用并丢弃而不回写到主内存)称为没有回写的缓存行失效。这是通过带有操作数的特殊指令执行的,该操作数可能(或可能不)指示要失效的高速缓存行的地址。

在我熟悉的所有架构中,这条指令都是特权,我认为这是有充分理由的。这意味着用户模式代码不能使用该指令;只有内核可以。否则可能发生的变态欺骗、数据丢失和拒绝服务的数量令人难以置信。

因此,没有内存分配器可以按照您的建议执行;他们根本没有(在用户模式下)这样做的工具。

架构支持

  • x86x86-64 架构具有特权invd 指令,它使所有内部缓存无效而不回写,并指示外部缓存也使自己无效。这是唯一可以在不回写的情况下失效的指令,它确实是一个钝器。
    • 非特权的clflush指令指定了一个受害者地址,但它在失效之前回写,所以我只是顺便提一下。
    • 所有这些指令的文档都在 Intel 的 SDMs,第 2 卷中。
  • ARM 体系结构使用a write to coprocessor 15, register 7:MCR p15, 0, <Rd>, c7, <CRm>, <Opcode_2> 执行高速缓存失效而不写回。可以指定牺牲缓存线。对此寄存器的写入具有特权。
  • PowerPCdcbi,它可以让你指定一个受害者,dci 和指令缓存版本两者都没有,但所有四个都是特权 (see page 1400)
  • MIPSCACHE 指令,可以指定受害者。它是特权as of MIPS Instruction Set v5.04,但在 6.04 中,Imagination Technologies 搅浑了水,不再清楚什么是特权,什么不是。

因此,这排除了使用缓存失效而无需在用户模式下彻底刷新/写回。

内核模式?

但是,我认为在内核模式下这仍然是一个坏主意,原因有很多:

  • Linux 的分配器kmalloc() 为不同大小的分配分配区域外。特别是,它有一个以8 为步长的每个分配大小<=192 字节的arena;这意味着对象可能比缓存线更靠近彼此或部分重叠下一个,因此使用失效可能会破坏附近正确位于缓存中但尚未写回的对象。这是错误
    • 缓存行可能非常大(在 x86-64 上为 64 字节),而且在整个缓存层次结构中的大小不一定一致,这使问题更加复杂。例如,Pentium 4 有 64B L1 缓存线,但有 128B L2 缓存线。
  • 它使释放时间与要释放的对象的缓存行数成线性关系。
  • 它的好处非常有限; L1 缓存的大小通常以 KB 为单位,因此几千次刷新将完全清空它。此外,缓存可能已经在没有您提示的情况下刷新了数据,因此您的失效比无用更糟糕:内存带宽已被使用,但您不再拥有缓存中的行,因此下次将部分写入时需要重新获取。
  • 下一次内存分配器返回该块时(可能很快),其用户将遭受有保证的缓存未命中并从主 RAM 获取,而他可能有一个脏的未刷新行或一个干净的刷新行。保证缓存未命中和从主 RAM 获取的成本大于缓存行刷新缓存硬件自动智能地隐藏在某处的缓存行。
  • 循环和刷新这些行所需的额外代码会浪费指令缓存空间。
  • 更好地利用上述循环所花费的几十个周期来使缓存线无效是继续做有用的工作,同时让缓存和内存子系统的相当大的带宽写回你的脏缓存线。
    • 我的现代 Haswell 处理器具有 32 字节/时钟周期写入 L1 带宽和 25GB/s 主 RAM 带宽。我确信可以在其中的某个地方挤入几个额外的可刷新 32 字节缓存线。
  • 最后,对于像这样的短期、小型分配,可以选择在堆栈上进行分配。

内存分配器实战

  • 著名的dlmalloc 不会使释放的内存无效。
  • glibc 不会使释放的内存无效。
  • jemalloc 不会使释放的内存无效。
  • musl-libc 的 malloc() 不会使释放的内存无效。

它们都不会使内存无效,因为它们不能。为了使缓存行无效而进行系统调用会非常慢,并且会导致更多的流量进出缓存,这仅仅是因为上下文切换。

【讨论】:

  • 很好的答案!不过,我并不完全同意反对内核模式使用的第一个论点。内核代码具有更高的正确性标准,这是有充分理由的。使仍然保存有效数据的缓存行无效是大约一千个可能的错误之一,每个都是错误。由于每个 arena 都有一个已知的分配大小,因此也可以轻松避免它。当这个大小小于缓存行大小时不要刷新。
  • @MSalters 嗯,问题是操作大小小于缓存行的对象、未对齐的对象或部分跨越两个缓存行的对象并不少见。它也将变得更加频繁;在 x86-64 的 L1 中,缓存线已经是 64 B,而英特尔 Kabylake 会将其提升到 256 B。以这种速度,您不会经常失效……您还让我想到了另一个问题.高速缓存不必全部共享相同的高速缓存行大小。 AFAIK 使用的 Pentium 4 64 B lines in L1 but 128 B in L2.
  • 好吧,x86 无论如何都没有使单个缓存行无效的指令,因为invd 会抛出所有内容。至于 L1/L2 大小差异,这可能不是一个真正的问题。丢弃 L1 行将消除对 L2 的写入。这增加了 L2 行保持“未修改”的机会,这意味着没有主存储器写入。但是,L2 行的另一半可能已经被修改,这意味着整行(正确)被写回,而右半部分已更新。
  • @MSalters 你所描述的不是完全失效吗?失效旨在从整个缓存层次结构中启动该地址,而不使用 WB,从而导致将来的访问从主 RAM 中取出。如果左半部分在 L1 中无效,那么它也必须从 L2 中消失;否则 L1 未命中将由 L2 提供服务。将两半写入内存会破坏没有 WB 的 inv 的目的以及 OP 的目的。恕我直言,唯一正确的解决方案是也使右半部分无效,并将其记录为操作系统开发人员的关注点,否则有说明识别它们所指的缓存。
  • 这不是绝对失效,但仍然是优化。 L2 比 L1 大很多;因此,丢弃 L1 行比丢弃 L2 行更有价值。此外,由于其他原因,我们可能不得不写回 L2 行,但这还远不能确定。如果该行被驱逐而另一半未修改而另一半从 L1 丢弃,我们仍然保存 L2->内存写入。
【解决方案2】:

我不知道有任何架构愿意将其缓存一致性协议公开给这样的软件(用户甚至内核)操作。这将产生实际上无法处理的警告。 请注意,用户启动的刷新是可接受的暴露,但绝不会威胁破坏内存的一致性。

例如,假设您有一个缓存行,其中包含您不再需要的临时数据。由于它被写入,它将在缓存中处于“修改”状态。 现在您需要一种机制来告诉缓存避免将其写回,但这意味着您创建了一个竞争条件 - 如果其他人在您应用此技巧之前寻找该行,他会从核心中窥探它并且收到更新的数据。如果你的核心先行,那么新数据就会丢失 - 因此,内存中该地址的结果取决于竞争。

您可能会争辩说,在多线程编程中经常出现这种情况,但是在运行单线程时也可能发生这种情况(如果缓存已满,CPU 可能会主动提前驱逐一行,或者某些较低的包含级别会丢失它) .更糟糕的是,这打破了整个虚拟内存看起来是平坦的前提,并且缓存版本由 CPU 维护只是为了性能,但不能破坏一致性或一致性(除了在一些记录的多线程情况下,取决于内存排序模型,这可以通过软件保护克服)。

编辑: 如果您愿意扩展您认为的“记忆”的定义,您可以寻找不连贯的记忆类型,它们在定义和实现上有所不同,但有些可能会提供您所寻找的东西。一些架构公开了“scratchpad”内存,它由用户控制,允许快速访问而没有缓存一致性的麻烦(但也没有它的好处)。有些架构甚至提供可配置的硬件,让您可以选择是喜欢在其中缓存主内存,还是将其用作暂存区。

【讨论】:

  • +1,完全正确。当我在回答中写到变态的欺骗、数据丢失和拒绝服务时,我就想到了这一点。除其他外,我想到了两个线程共享一个超线程核心。
【解决方案3】:

这在很大程度上取决于您正在使用的实现和库。分配和释放的内存往往会很快重新分配。大多数分配都在比需要时写入后备存储的页面小得多的小块中。

今天,RAM 大小通常非常大,以至于当操作系统开始将脏页写入后备存储时,无论如何你都会遇到麻烦。如果您有 16 GB 的 RAM,您将不会写入 100 KB 或 1 兆字节,您将写入千兆字节,并且您的计算机将慢下来。用户将通过不使用使用过多内存的应用程序来避免这种情况。

【讨论】:

  • 我实际上对 CPU 缓存级别比将页面写入后备存储更感兴趣。但是,CPU 缓存和主存之间的关系确实类似于主存和后备存储之间的关系。
  • 实际上有很大的不同。删除的文件刷新到磁盘。不同的原因是相关的簿记确实丢弃这样的页面类似于丢弃脏 L1 行所需的方法,但不将页面写入磁盘所节省的成本要大几个数量级。
【解决方案4】:

相当多的分配器将“空闲块列表”存储在空闲块本身中。 IE。当您调用该释放函数时,分配的块被拼接到空闲列表中,这可能意味着用前向和后向指针覆盖旧数据。这些写入至少会覆盖分配的第一部分。

分配器使用的第二种技术是积极回收内存。如果下一次分配可以与最新的释放匹配,则很可能缓存没有刷新到主内存。

您的想法的问题在于,每个单独的写入实际上并不那么昂贵,并且弄清楚可以丢弃的内容将涉及相当多的昂贵簿记。实际上,您无法进行系统调用。这意味着您需要在每个应用程序中进行簿记(这是合理的:这些小块的释放通常会将内存返回给应用程序,而不是操作系统)。这反过来意味着应用程序需要了解 CPU 缓存设计,这绝不是一成不变的。应用程序甚至需要了解不同的缓存一致性方案!

【讨论】:

  • 鉴于应用程序通过内存管理系统与内存接口,其默认实现至少随编译器/标准库一起提供,我认为应用程序级代码不需要了解缓存布局。相反,“只有”内存管理系统需要知道(在该级别上,还需要知道已释放内存的哪一部分继续用于内存管理系统的目的)。
  • @DietmarKühl:我在这里使用“应用程序”的意思是“不是内核”。在 x86 用语中,环 3 而不是环 0。实际上,您不仅可以通过标准库来实现这一点,还可以让操作系统将包含相关指令的代码页映射到每个进程中。
【解决方案5】:

您在这里提出了许多相关问题。黑体字是最容易回答的。当您使用任何类似通用释放的方式释放内存时,您所说的唯一 是“我不再需要这个”。你含蓄地说,“我不在乎你用它做什么”。这个“我不在乎”实际上是您问题的答案。你不是说“你可以丢弃这个”。你说“我不在乎你是否丢弃它”。

为了回答您关于 CPU 支持的问题,MSI protocol 是一个基本的缓存一致性协议。 I 状态代表“无效”,这是您如何实现您所询问的“未使用内存”状态的方式。为此,您需要创建一个具有非泛型语义的发布接口,也就是说,这种发布意味着“不再使用此内存并且您应该避免将其写回 main记忆”。请注意,这种语义对 CPU 的行为有一个通用版本没有的要求。要实现这一点,您需要根据 CPU 缓存分配内存,然后使用可用的 CPU 指令使缓存项无效。您几乎肯定需要编写汇编代码来完成这项工作,以避免使用显式缓存管理指令会导致对内存模型的无根据(和不正确)假设。

我个人已经有一段时间不需要在这个级别上工作了,所以我不熟悉随处可用的东西,也就是说,这种技术是否可以合理地移植。 Intel CPU 有INVLPG 指令。这里的讨论应该是您关注下一阶段的一个不错的起点:When to do or not do INVLPG, MOV to CR3 to minimize TLB flushing

【讨论】:

  • INVLPG 使 TLB 条目无效;这与数据缓存不同,远非如此。此外,它是一条特权指令,因此您不能从用户代码中使用它。您链接到的页面谈论的是在内核模式下完成的活动,而不是在用户模式下。正如我在下面的回答中指出的那样,您不能也不应该能够在任何平台上编写用户模式失效且不回写的代码。
  • @IwillnotexistIdonotexist 正如我在回答中所说,我不熟悉如何实现这一点的具体细节;鉴于你的回答,你显然是。我提到该指令只是为了指出在您弄清楚要实现什么之后您希望开始的方向。最初的问题是关于这些调用的正确语义,这是我关注的重点。
  • 非通用版本确实是应该避免写回。这意味着它不是一个实际的 shall 要求。请注意,这特别是在内存分配器的上下文中,它通常作为 C 或 C++ 实现的一部分出现。 “汇编”并不是一个缺点,编译器一直使用它。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-03-16
  • 2016-06-07
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多