Arm64内存模型、内存类型、性能与DMA

news/2024/10/25 1:49:39

一、背景

写下本文的原因来自一次 bug 排查,平台为某个 Arm64 处理器。

问题简单来说就是,就是申请一块 dma-buf 并映射到用户空间,对 buffer 使用memcpy()时发现一些异常性能问题:

  1. 从 dma-buf 向通过malloc()申请的普通堆内存拷贝速度,远慢于从普通堆内存向 dma-buf 拷贝的速度,差距能有十倍以上,结果如下,数据大小为1M

    dma-buf --> heap memory 550 us
    heap memory --> dma-buf 49 us
  2. 对于(1)的现象,我怀疑 dma-buf 的读速度慢于写速度,也远慢于普通内存的读写速度,且 dma-buf -> 普通内存拷贝慢的原因很像 cache miss 导致的,然而问题是

    1. 如果是 cache miss 导致,为什么只有读的速度慢?在我的认知中写速度应该一样很慢
    2. 如果是 cache miss 导致,为什么是一直都慢?在我的认知中CPU应该有机制会对内存预缓存,不可能一直 cache miss
  3. 同事用 perf 进行初步分析,发现更奇怪的现象

    1. dma-buf 向普通内存拷贝的 cache miss 率非常低,不足 0.5%,反而普通内存向 dma-buf 拷贝的 cache miss 率很高,有 16%
    2. perf 显示,dma-buf 向普通内存拷贝过程耗时最长的地方在一条寄存器减法指令,这条指令在一个拷贝128字节的循环体的末尾,理论上来说这种指令应该是所有指令中执行最快、耗时最少的,为何此时却显示为执行耗时最多的?
  4. 从(3)来看,似乎导致(1)异常的原因不是 cache miss,也不是读速度慢于写速度,但也无法解释(1)中异常,于是我单测对 dma-buf 和普通内存的只读、只写速度,源码如下

    // 测试内存连续地址的只读性能
    void test_read(void* addr, int size) {void* addrDst = addr + size;while (addr != addrDst) {asm volatile ("ldp x3, x4, [%[addr]], #16 \n": [addr] "+rw"(addr)::);}
    }// 测试内存连续地址的只写性能
    void test_write(void* addr, int size) {void* addrDst = addr + size;while (addr != addrDst) {asm volatile ("stp x3, x4, [%[addr]], #16 \n": [addr] "+rw"(addr)::);}
    }
    

    结果如下

    dma-buf heap memory
    只读 537 us 31 us
    只写 30 us 32 us

    这个结果比较符合我(2)中最开始的猜想,也就是 dma-buf 的读速度远慢于写速度,但产生这种现象的原因,以及为何与(3)中 perf 指向结论相悖的原因都让人不解。

    本文接下来就是解释其中原因。

二、DMA

先简单介绍 DMA 和 dma-buf。

1. DMA 与缓存一致性问题

在还没 DMA 技术的时候,外设通常与 CPU 直连,因此 CPU 要处理大量 I/O 任务,其中数据传输任务尤其浪费 CPU 资源,所有数据都要通过 CPU 中转才能写入内存,然后 CPU 再处理数据,会消耗大量 CPU 时间,而 DMA 技术就是用来将 CPU 从繁重的 I/O 任务中解脱。

简单来说,外设连接在一个 DMA 控制器上,DMA 控制器直连内存,当有 I/O 任务时,DMA 控制器可以独立对内存读写数据,而不需要经过 CPU ,因此从体系结构而言,CPU 可以和 I/O 设备(DMA 控制器)并行工作。

这种设计虽然解决了 I/O 任务对 CPU 的高负载问题,但也引入了新问题,比如缓存一致性问题。

大多数现代 CPU 上都有缓存,它们虽然极大降低 CPU 访存延迟,提升 CPU 运行效率,但也引入数据一致性问题,因此需要多种机制保证不同级别的缓存、不同 CPU 簇的缓存以及与内存间的数据被 CPU 访问时应该一致,且应尽可能降低对性能的影响。

而 DMA 则让缓存一致性问题更加复杂,由于 DMA 不能访问缓存,因此当 CPU 对一个物理地址写入时,它可能写入的是缓存,而内存上的数据并未实时更新,若 DMA 此时访问这块内存,则读取的都是未实时更新的数据,从而产生数据一致性问题。

而 DMA 数据一致性问题,就是导致本文问题的根因。

2. dma-buf

严谨地说,dma-buf 是 Linux 内核的一个子系统,主要用于在不同驱动间、驱动内核态与用户态、用户态线程/进程间共享缓冲区。

(后面若无特别说明,dma-buf 用于指代 dma-buf 子系统申请的缓冲区)

通常 dma-buf 是驱动在内核中申请的,可通过文件描述符共享给其它驱动使用,或通过mmap映射到用户空间,用户空间通过虚拟地址访问。

例如 DRM 申请的内存,尤其是 GEM 申请的内存,通常就是基于 dma-buf 实现的设备内存,GPU 驱动或 EGL 这种 API,就是通过 dma-buf 建立普通图形应用和窗口合成器之间的高效交换链机制,前后台缓冲区就是在应用和合成器之间共享的 dma-buf。

在嵌入式计算机上,由于通常是 UMA 架构,因此CPU、GPU、DMA 之间可以通过 dma-buf 实现零拷贝的内存共享。

三、Arm64 内存模型与内存类型简介

本节只介绍关联问题的知识,更多知识请参阅其它资料。

1. 内存模型

(1) 弱排序访问

Arm 的内存实现了弱排序架构——允许实际访存顺序与程序指定顺序不同,但最终运行结果与程序预期相同。

这种架构的设计目的自然是为了提升性能,我们以 Arm 官方例子展示

上图讲得非常清楚,可能有读者看不懂 Arm 汇编,我简单解释一下。

图中左侧有三条指令,它们从上至下按顺序依次执行。

第一条指令将寄存器 R12 中的数据放到寄存器 R1 中数据指向的地址,这是一次写内存。

第二条指令是将 SP 寄存器的值作为地址指向的数据放到 R0 寄存器中,然后对 SP 寄存器的值加 4,这是一次读内存。

第三条指令是将 R3 寄存器的值加 8,并将新值作为地址指向的数据存放到 R2 寄存器中,这也是一次读内存。

图中右侧则是 CPU 实际执行的顺序。

首先 CPU 将 R12 寄存器中的数据放进写缓存中。写缓存简单来说是一种 CPU 缓存结构,若写操作未命中 L1 cache,则会将数据先放进其中,CPU 此时认为写操作已经完成,而只有当 cache 或内存准备好响应写操作后,数据才会从写缓存真正写进相应物理区域中。关于写缓存的具体细节,我们将在后文中介绍。

紧接着,执行第二条指令,CPU 访存但未命中,因此它会触发 cache 去映射内存数据,此时数据并未写入 CPU。

当执行第三条指令时,CPU 访存并命中,因此数据很快会立刻写进 CPU,访存操作完成,而这是三条指令中第一条真正完成内存操作的指令。

再然后,第二条指令触发的缓存映射完成,数据从缓冲中写入 CPU,第二条指令内存操作执行完毕。

最后,第一条指令触发的内存存储请求完成响应,数据从写缓存中写入内存,第一条指令操作执行完毕。

从时间顺序上,可以得到下面这张图。

虽然指令1 执行最早,但由于内存访问可以不依赖实际访问顺序,因此它是最晚执行完的,三条指令执行的总时间就是指令1 执行的时间。

而若强制要求访存顺序必须按照程序执行顺序完成,则会发生下面这种情况

可以看到,因为访存顺序必须按照指令顺序,所以指令2 和指令3 的访存操作必须在前面的指令访存执行完后才能执行,故而指令进入流水线后,会在执行阶段被阻塞,产生巨大的气泡。所以三条指令的总执行时间基本等于三者访存时间之和。

只看例子中的情况,似乎下面只比上面多两次访存时间消耗,但实际上指令3 之后还有指令,因此可能后续若干条指令都能在指令1 访存结束前完成。而下面这种情况,很可能导致后面的指令都被阻塞,时间消耗不断挤压,因此最终的耗时远高于上面这种情况。

当然,Arm 的内存并不是只有弱排序的,某些情况下内存不仅必须是强排序的,还有其它各种特性,这将在后面介绍。

(2) 写合并与写缓存

写缓存是一种未对软件开发者暴露细节的缓存,Arm64 也未对其做出规定,通常由 vendor 自行实现,开发者最多知晓 CPU 存在这样一种缓存,因此我主要从其一般性原理出发来阐述。

写缓存通常位于 L1 cache 到内存之间,当写内存操作未命中 L1 cache 时,数据首先会写入写缓存中,此时 CPU 执行单元会认为数据已经写入相应位置中,并继续执行内存读写操作。只有当下级缓存或内存准备相应写入操作后,数据再从写缓存真正写入相应的物理单元中。

通常写缓存支持写合并,这种一种利用写缓存有效提升写内存效率的方式,具体如下。

如下图所示,假设一个写缓冲有四个写条目,每个写条目有四项,每项可以存放8个字节。现在要处理器要将地址 100 - 131 的32个字节数据写入到相关存储器中,L1 cache 未命中,因此数据先写入写缓存。

假设未使用写合并,则每 8 个字节占据一个写条目,32 个字节占据 4 个写条目,当相关存储器准备好写入后,4 个写条目按 4 次写入存储器中。

而若开启写合并,则 32 个字节放在一个条目中,当存储器准备好写入时,32 个字节优化为一次写,从而降低访存耗时。

此外,写合并可以让多个对相同地址的相同类型的内存访问(读或写)操作合并为一个,比如,连续几次对地址 108 的写操作,会在写缓冲上进行,当存储器准备后写入后,Mem[108] 的数据才实际写入存储器中,因此多个处理器的写操作就合并为一个。

2. 内存类型

Arm64 体系结构中有两种类型内存——普通内存 (Normal Memory)、设备内存 (Device Memory),二者互斥,且内存一定为这两种中的一种。

两种内存类型又可以分为多种确定的内存属性,早期 Arm64 支持 9 种类型,但随着技术发展,比如 Memory Tagging 这种技术的出现,Arm64 逐渐支持更多的类型属性,而任何一种内存一定属于其中一种。

Arm64 支持操作系统自由选择要使用的内存属性,并自由对内存分配属性,后文会讲到如何分配。

通常内存类型是操作系统或驱动在内核态分配内存时确定的,一般的系统调用没有办法指定申请的内存类型,但不排除某些驱动暴露给用户态的接口可以申请指定类型的内存。

(1) 普通内存

绝大部分内存都是普通内存,包括所有的代码区 (text segement)、大多数数据区 (data segement);从物理元件上来说,可以包括 RAM、ROM、闪存等。

普通内存是弱排序内存,因此性能在所有内存类型里是最高的,支持编译器优化和处理器重排序、合并访问等优化。若需要对普通内存强制内存访问排序,则需要用显式的内存屏障实现。

普通内存包含多种具体的内存属性,不同的内存属性有不同的读写策略,比如关闭缓存、回写策略、写直通策略等,以 6.12 Linux 源码为例,使用了三种:

// <arch/arm64/include/asm/memory.h>
#define MT_NORMAL		0
#define MT_NORMAL_TAGGED	1
#define MT_NORMAL_NC		2

其中MT_NORMAL_TAGGED是使用了 Memory Tagging 技术的普通内存,这里不详细展开讲。MT_NORMAL_NC则是关闭缓存使用的普通内存。

普通内存的介绍到此为止,不是本文重点,更多信息请参阅其它文档。

(2) 设备内存

设备内存类型的内存通常用于外设寄存器映射,比如用于 DMA 访问。某些情况下,由于外设访问模式的特殊性,需要限制对内存访问的优化,以保证数据的读写完全遵循程序指令的要求,此外还有缓存一致性的问题,所以需要一种与普通内存访问形式不同的内存。

因此,dma-buf 在相当多时候都是以设备内存来实现的

设备内存最大的特点就在于其永远不可缓存,因此所有读写都必须在物理内存上完成,而这就某些 dma-buf 读写速度慢的根因

而在此基础上,设备内存还有六种内存属性来指定其读写策略,这 6 种读写策略根据三种优化内存读写的行为来规定:

  1. G/nG:G (Gathering) 表示聚合,即允许把多次访存操作合并为一次,而这个合并操作,就是我们前面说的“写合并”。
  2. R/nR:R (Re-ordering) 表示指令重排,即允许读写操作指令重排。
  3. E/nE:E (Early write acknowledgement) 表示提前写应答,即当数据写入写缓存后,写应答立刻返回给处理器,因此处理器能进行后续访存操作。而若关闭写应答 (nE),则数据达到外设后(专业术语为端点,外设就是一种端点)才返回写应答。

三种行为是否开启共同决定一种具体的内存属性,并且可能相互影响,Arm64 允许的设备内存属性与包含关系如下图所示:

可以看到 Device-nGnRnE 是限制最严格的内存属性,同时理论上也是性能最差的,而 Device-GRE 是限制最少的内存属性,理论上也是性能最好的。

但上述只是理论上,实际的处理器很可能并不能支持所有行为限制,比如在有的处理器上 Device-GRE 和 Device-nGRE 行为相同,而,最终实际要处理器的具体表现。

6.12 Linux 源码中使用了如下两种设备内存属性

// <arch/arm64/include/asm/memory.h>
#define MT_DEVICE_nGnRnE	3
#define MT_DEVICE_nGnRE		4

(3) 内存属性的设置

操作系统可以将需要的内存属性放在 MAIR_ELn (Memory Attribute Indirection Register_ ELn) 寄存器中,MAIR_ELn 一共有 8 个,也就是说 Arm64 最多支持使用 8 种不同的内存属性。

6.12 Linux 源码设置 MAIR_ELn 方式如下

// linux/arch/arm64/mm/proc.S
#define MAIR_EL1_SET							\(MAIR_ATTRIDX(MAIR_ATTR_DEVICE_nGnRnE, MT_DEVICE_nGnRnE) |	\MAIR_ATTRIDX(MAIR_ATTR_DEVICE_nGnRE, MT_DEVICE_nGnRE) |	\MAIR_ATTRIDX(MAIR_ATTR_NORMAL_NC, MT_NORMAL_NC) |		\MAIR_ATTRIDX(MAIR_ATTR_NORMAL, MT_NORMAL) |			\MAIR_ATTRIDX(MAIR_ATTR_NORMAL, MT_NORMAL_TAGGED))SYM_FUNC_START(__cpu_setup)tlbi	vmalle1				// Invalidate local TLBdsb	nshmsr	cpacr_el1, xzr			// Reset cpacr_el1mov	x1, #1 << 12			// Reset mdscr_el1 and disablemsr	mdscr_el1, x1			// access to the DCC from EL0reset_pmuserenr_el0 x1			// Disable PMU access from EL0reset_amuserenr_el0 x1			// Disable AMU access from EL0/** Default values for VMSA control registers. These will be adjusted* below depending on detected CPU features.*/mair	.req	x17tcr	.req	x16mov_q	mair, MAIR_EL1_SETmov_q	tcr, TCR_T0SZ(IDMAP_VA_BITS) | TCR_T1SZ(VA_BITS_MIN) | TCR_CACHE_FLAGS | \TCR_SHARED | TCR_TG_FLAGS | TCR_KASLR_FLAGS | TCR_ASID16 | \TCR_TBI0 | TCR_A1 | TCR_KASAN_SW_FLAGS | TCR_MTE_FLAGStcr_clear_errata_bits tcr, x9, x5

然后在分配内存时,通过在 L3 页表项的低位属性 AttrIndx[2:0] 中指定

AttrIndx[2:0] 可以表示 8 个二进制数,刚好对应 8 个 MAIR_ELn 寄存器。

因此,当处理器将虚拟地址翻译为物理地址时,会根据 L3 页表项的 AttrIndx[2:0] 的值,去对应的 MAIR_ELn 寄存器查询这个页面的内存属性是什么,然后根据这个内存属性来决定对这块内存的读写行为,也就是说,内存属性实际作用的最小单位是页面,而非某个具体的地址。

四、缓存一致性相关

如前所说,dma-buf 的读写性能问题,核心在于其选择使用设备内存类型,因此关闭了缓存加速,同时可能由于写缓存与写合并机制,导致其读速度慢,而写速度快。

但实际工作中发现,并不是所有的 dma-buf 和设备使用的内存都是设备内存类型.

比如:可通过 libion 申请可缓存的 dma-buf;某些高通平台 OpenGL ES 申请的图形内存都是普通内存类型,且可以映射到用户地址空间中;某些嵌入式平台上 Vulkan 可以申请内存属性为 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT(表示可被主机(通常为 CPU)访问、缓存)、堆标志为VK_MEMORY_HEAP_DEVICE_LOCAL_BIT(表示为设备(通常为 GPU)本地)的内存。

也就是说,实际中外设使用的内存并不一定非得是设备内存类型,也可以是普通内存类型,但这里的问题是如何保证普通内存类型的内存对外设的一致性呢?这个问题的答案涉及到了现实中处理器的缓存一致性处理方案。

1. 共享域

Arm64 根据数据的共享范围,可以分为四个共享域,在某个共享域中,所有可访存的硬件都要实现缓存一致性,四个共享域如下所示

  1. 不可共享:通常就是一个 CPU,它有独立的 L1 cache,不可被其它 CPU 访问。
  2. 内部共享域:通常是一个 CPU 簇,比如四个 CPU 组成一个 CPU 簇,它们共享相同的 L2 cache。
  3. 外部共享域:通常是若干 CPU 簇构成,不同 CPU 簇可能是异构的(微架构不同),但它们可以共享 L3 cache。
  4. 系统共享域:系统中所有可访存的硬件单元,除了 CPU 外,还可能有 DMA、GPU、NPU 等等。

2. 缓存一致性实现

不同共享域的缓存一致性通常需要不同的方式来实现,比如内部共享域通常就是通过大名鼎鼎的 MESI 协议来实现,MESI 协议是一个完全硬件实现的协议,对软件透明,但某些情况下也需要手工干预,比如涉及到 DMA 缓冲时,就需要手动刷新数据到内存,因为 MESI 协议并不保证和 DMA 设备(也就是系统共享域)的缓存一致性。

同样的,MESI 协议也不能保证外部共享域一致性,现在很多移动端 CPU 都采用大小核异构架构,不同架构的 CPU 构成至少一个 CPU 簇,而 CPU 簇之间的缓存一致性则由其它协议来实现,比如 AMBA 协议。

MESI 协议通常由 CPU 内部的缓存控制器就可以实现,但 AMBA 不一样,它需要独立于 CPU 的专门控制器单元来实现,如下图所示。

从图中可以看到,除了两个 CPU 簇通过 ACE (AXI Coherent Extension) 协议完成外部共享域的缓存一致性外,GPU 和 DMA 也可以通过 ACE Lite 协议连接到 AMBA 控制器上,完成系统共享域的实现。

但 AXI 是 AMBA 4 中才引入的,早期并没有,因此早期如 DMA、GPU 等外设并不能通过 AMBA 来实现缓存一致性,而这也是为什么设备内存类型存在的原因。假设一颗 SoC 并没有引入相关协议来实现系统域的缓存一致性,那么通常就只能通过关闭缓存或软件管理的方式来保证 DMA 或其它非 CPU 的访存设备的缓存一致性。

由于 Linux 内核通常不能保证硬件平台有系统域的缓存一致性硬件实现,所以默认对为外设使用内存的实现都会比较保守,使用设备类型内存。

但若硬件平台支持硬件实现系统域缓存一致性,甚至支持 SMMU (IOMMU),即 I/O 设备的地址管理,那么便可使用普通类型的内存用于 GPU、DMA 等外设使用。

而 vendor 或第三方可以在驱动中覆盖或新增 Linux 原生的某些实现,从而使用普通类型内存代替默认的设备类型内存。
文章的最后放一张高通官方给的体系结构图,虽然已经是比较早的 SoC 了,但也可以看到复杂的系统域级缓存一致性控制实现。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.ryyt.cn/news/75707.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈,一经查实,立即删除!

相关文章

[rCore学习笔记 030] 虚拟地址与地址空间

时隔很久,终于忙里偷闲可以搞一搞rCore,上帝啊,保佑我日更吧,我真的很想学会. 导读部分 首先还是要看官方文档. 我决定看一遍然后自己表述一遍(智将). 这里反复提到MMU,就是因为之前学MCU的时候有一个疑问,就是为什么MCU上不选择跑一个Linux,当时找到的答案是因为没有MMU. MMU的…

11. 使用MySQL之使用数据处理函数

1. 函数 与其他大多数计算机语言一样,SQL支持利用函数来处理数据。 函数一般是在数据上执行的,它给数据的转换和处理提供了方便。 在前一章中用来去掉串尾空格的RTrim()就是一个函数的例子。 补充: 函数没有SQL的可移植性强 能运行在多个系统上的代码称为可移植的(portable…

【CodeForces训练记录】Codeforces Round 981 (Div. 3)

https://codeforces.com/contest/2033 训练情况 22队长率先开出E题,但是结局可能还是掉分了 TAT赛后反思 这场太板了,D题有点反常(存疑?) A题 我们直接模拟位置的变化就行,先手 \(-2 \times i - 1\) 后手 \(+ 2 \times i - 1\),用一个while找到 \(>n\) 的地方来结束循…

东山Pi柒号-4-STM32MP157 TF-A移植

STM32MP157 TF-A 移植 在了解了 STM32MP 系列芯片的启动流程后,我们将开始进行东山 Pi 柒号的 TF-A 移植。 准备工作 首先,我们需要下载 STM32MP1 系列的 STM32MPU_Developer_Package,该包中包含编译器 SDK 和官方源码:STM32MP1 OpenSTLinux 开发套件 https://www.st.com.c…

7-1将数组中的数逆序存放

24级一维数组 题目不难,就是格式啥的要看仔细楼#include<stdio.h> int main (){int a[11] = {0};int num;int input;scanf("%d",&num);for(int i=num-1;i>=0;i--){//逆序存放!!!scanf("%d",&input);a[i] = input;}for(int i=0;i<n…

【投资理财】一起来探索金融理财世界啦

各位程序员小伙伴们,大家都知道最近大 A 股市那叫一个起伏不定啊,就像坐过山车似的,刺激得很。咱程序员平时工作忙归忙,但不少同学对炒股还挺感兴趣的,甚至有的同学在工作的时候还会偷偷摸摸瞅几眼股市行情😜。我最近发现了一些很不错的金融理财资源,想着赶紧分享给大家…

无法删除文件,因为已在Windows资源管理器中打开

背景 文件夹/文件删不掉 解决 直接重启explorer即可。 win+x,a 打开终端。 kill -name explorer按理来说关闭后explorer会自动重启 start explorer图形界面方式 Ctrl+Shift+ESC,打开任务管理器。 点击详细信息,按名称排序找到explorer.exe,右键重新启动。