内存模型放松注解

简介

内存模型放松注解(MMRA)是指令上的目标定义属性,可用于有选择地放松内存模型施加的约束。例如

  • 在 SPIRV 程序中使用 VulkanMemoryModel 允许某些内存操作在 acquirerelease 操作之间重新排序。

  • OpenCL API 公开了仅对特定地址空间进行栅栏的原语。将此信息传递到后端可以启用使用更快的同步指令,而不是每次都对所有地址空间进行栅栏。

MMRA 为目标提供了选择性地放松默认 LLVM 内存模型的系统。因此,它们使用 LLVM 元数据附加到操作,并且始终可以在不影响正确性的情况下删除。

定义

内存操作

加载、存储、原子操作或标记为访问内存的函数调用。

同步操作

与其他线程同步内存的指令(例如,原子操作或栅栏)。

标签

附加到内存或同步操作的元数据,表示关于内存同步的一些目标定义属性。

一个操作可以有多个标签,每个标签代表不同的属性。

标签由一对元数据字符串组成:前缀后缀

在 LLVM IR 中,使用元数据元组表示该对。在其他情况下(注释、文档等),我们可能会使用 prefix:suffix 表示法。例如

清单 20 示例:元数据中的标签
!0 = !{!"scope", !"workgroup"}  # scope:workgroup
!1 = !{!"scope", !"device"}     # scope:device
!2 = !{!"scope", !"system"}     # scope:system

注意

与优化器相关的唯一语义是下面定义的“兼容性”关系。所有其他语义都是目标定义的。

标签也可以组织成列表,以允许操作指定其所属的所有标签。这样的列表称为“标签集”。

清单 21 示例:元数据中的标签集
!0 = !{!"scope", !"workgroup"}
!1 = !{!"sync-as", !"private"}
!2 = !{!0, !2}

注意

如果操作没有 MMRA 元数据,则将其视为具有空标签列表(!{})。

请注意,如果标签未被其应用到的指令或当前目标识别,则不会出错。此类标签将被简单地忽略。

同步操作和内存操作都可以使用 !mmra 语法附加零个或多个标签。

为了提高以下示例的可读性,我们使用(非功能性)简写语法来表示 MMMRA 元数据

清单 22 简写语法示例
store %ptr1 # foo:bar
store %ptr1 !mmra !{!"foo", !"bar"}

本文档中可以使用这两种表示法,它们严格等价。但是,只有第二个版本是功能性的。

兼容性

如果对于至少在一个集合中存在的每个唯一的标签前缀 P

  • 另一个集合不包含具有前缀 P 的标签,或者

  • 至少一个具有前缀 P 的标签是两个集合共有的。

上述定义意味着空集始终与任何其他集合兼容。这是一个重要的属性,因为它确保如果转换删除操作上的元数据,则永远不会影响正确性。换句话说,通过从指令中删除元数据,内存模型不能被进一步放松。

先行发生关系

兼容性检查可用于选择退出两个指令之间建立的先行发生关系。

排序

当两个指令的元数据不兼容时,它们之间的任何程序顺序都不在先行发生中。

例如,考虑目标公开的两个标签 foo:barfoo:baz

A: store %ptr1                 # foo:bar
B: store %ptr2                 # foo:baz
X: store atomic release %ptr3  # foo:bar

在上图中,AX 兼容,因此 A 先行发生于 X。但 BX 不兼容,因此它不先行发生于 X

同步

如果同步操作具有一个或多个标签,则它是否与其他操作同步以及是否参与 seq_cst 顺序取决于目标。

以下示例是否与另一个序列同步取决于 foo:barfoo:bux 的目标定义语义。

fence release               # foo:bar
store atomic %ptr1          # foo:bux

示例

示例 1
A: store ptr addrspace(1) %ptr2                  # sync-as:1 vulkan:nonprivate
B: store atomic release ptr addrspace(1) %ptr3   # sync-as:0 vulkan:nonprivate

A 和 B 相对彼此无序(无先行发生),因为它们的标签集不兼容。

请注意,sync-as 值不必与 addrspace 值匹配。例如,在示例 1 中,对 addrspace(1) 中位置的存储释放操作只想与 addrspace(0) 中发生的操作同步。

示例 2
A: store ptr addrspace(1) %ptr2                 # sync-as:1 vulkan:nonprivate
B: store atomic release ptr addrspace(1) %ptr3  # sync-as:1 vulkan:nonprivate

A 和 B 的顺序不受影响,因为它们的标签集兼容。

请注意,由于其他原因,A 和 B 可能会或可能不会在先行发生中。

示例 3
A: store ptr addrspace(1) %ptr2                 # sync-as:1 vulkan:nonprivate
B: store atomic release ptr addrspace(1) %ptr3  # vulkan:nonprivate

A 和 B 的顺序不受影响,因为它们的标签集兼容。

示例 4
A: store ptr addrspace(1) %ptr2                 # sync-as:1
B: store atomic release ptr addrspace(1) %ptr3  # sync-as:2

A 和 B 不必相对于彼此排序(无先行发生),因为它们的标签集不兼容。

用例

SPIRV NonPrivatePointer

MMRA 可以支持 SPIRV 功能 VulkanMemoryModel,其中同步操作仅影响指定 NonPrivatePointer 语义的内存操作。

以下示例是从使用以下方法生成的 SPIRV 程序生成的

  • vulkan:nonprivate 添加到每个同步操作。

  • vulkan:nonprivate 添加到标记为 NonPrivatePointer 的每个非原子内存操作。

  • vulkan:private 添加到未标记为 NonPrivatePointer 的每个非原子内存操作的标签中。

Thread T1:
 A: store %ptr1                 # vulkan:nonprivate
 B: store %ptr2                 # vulkan:private
 X: store atomic release %ptr3  # vulkan:nonprivate

Thread T2:
 Y: load atomic acquire %ptr3   # vulkan:nonprivate
 C: load %ptr2                  # vulkan:private
 D: load %ptr1                  # vulkan:nonprivate

兼容性确保操作 A 相对于 X 有序,而操作 D 相对于 Y 有序。如果 XY 同步,则 A 先行发生于 D。无法推断关于操作 BC 的此类关系。

注意

Vulkan 内存模型 将所有原子操作视为非私有。

是否会在原子操作上指定 vulkan:nonprivate 是实现细节,因为原子操作始终为 nonprivate。实现可以选择明确并发出带有 vulkan:nonprivate 的 IR 的每个原子操作,或者可以选择仅发出 vulkan::private 并默认假设 vulkan:nonprivate

标记有 vulkan:private 的操作有效地选择退出 SPIRV 程序中的先行发生顺序,因为它们与每个同步操作都不兼容。请注意,未标记为 NonPrivatePointer 的 SPIRV 操作并非完全对线程私有——它们在 Vulkan 系统同步关系的线程开始或结束时隐式同步。此示例假设 vulkan:private 的目标定义语义正确地实现了此属性。

此方案足够通用,可以表达 SPIRV 程序与其他环境的互操作性。

Thread T1:
A: store %ptr1                 # vulkan:nonprivate
X: store atomic release %ptr2  # vulkan:nonprivate

Thread T2:
Y: load atomic acquire %ptr2   # foo:bar
B: load %ptr1

在上面的示例中,线程 T1 来自 SPIRV 程序,而线程 T2 来自非 SPIRV 程序。X 是否可以与 Y 同步是目标定义的。如果 XY 同步,则 A 先行发生于 B(因为 A/X 和 Y/B 兼容)。

实现示例

考虑在所有内存操作都缓存且整个缓存会在 releaseacquire 操作时分别被刷新或失效的目标上实现 SPIRV NonPrivatePointer。一种可能的方案是在翻译 SPIRV 程序时,标记为 NonPrivatePointer 的内存操作不应该被缓存,并且在 acquirerelease 操作期间不应该触碰缓存内容。

这可以使用带有 vulkan: 前缀的标签来实现,如下所示

  • 对于内存操作

    • 带有 vulkan:nonprivate 的操作应该绕过缓存。

    • 带有 vulkan:private 的操作应该被缓存。

    • 既没有指定也没有同时指定两者操作的,应该保守地绕过缓存以确保正确性。

  • 对于同步操作

    • 带有 vulkan:nonprivate 的操作不应该刷新或使缓存失效。

    • 带有 vulkan:private 的操作应该刷新或使缓存失效。

    • 既没有指定也没有同时指定两者操作的,应该保守地刷新或使缓存失效以确保正确性。

注意

在这种实现中,删除操作上的元数据虽然不会影响正确性,但可能会对性能产生重大影响。例如,操作绕过缓存时本不应该绕过。

内存类型

MMRA 可以表达对不同内存类型的选择性同步。

例如,目标可以公开一个 sync-as:<N> 标签来传递有关同步操作的执行同步了哪些地址空间的信息。

注意

这里使用地址空间作为通用示例,但此概念可以应用于其他“内存类型”。“内存类型”在这里的含义由目标决定。

# let 1 = global address space
# let 3 = local address space

Thread T1:
A: store %ptr1                                  # sync-as:1
B: store %ptr2                                  # sync-as:3
X: store atomic release ptr addrspace(0) %ptr3  # sync-as:3

Thread T2:
Y: load atomic acquire ptr addrspace(0) %ptr3   # sync-as:3
C: load %ptr2                                   # sync-as:3
D: load %ptr1                                   # sync-as:1

在上图中,XY 是对 global 地址空间中某个位置的原子操作。如果 XY 同步,则 Blocal 地址空间中先于 C 发生。但对于操作 AD,虽然它们是在 global 地址空间中的某个位置上执行的,但无法做出此类声明。

实现示例:向栅栏添加地址空间信息

诸如 OpenCL C 之类的语言提供诸如 atomic_work_item_fence 之类的栅栏操作,这些操作可以采用显式地址空间进行栅栏。

默认情况下,LLVM 无法在 IR 中携带该信息,因此在降低到 LLVM IR 期间信息会丢失。这意味着诸如 AMDGPU 之类的目标必须在所有情况下都保守地发出指令来对所有地址空间进行栅栏,这可能会对高性能应用程序的性能产生明显的影响。

MMRA 可用于在 IR 级别保留该信息,一直到代码生成。例如,仅影响全局地址空间 addrspace(1) 的栅栏可以降低为

fence release # sync-as:1

并且目标可以使用 sync-as:1 的存在来推断它必须仅发出对全局地址空间进行栅栏的指令。

请注意,由于 MMRA 是选择加入的,因此没有 MMRA 元数据的栅栏仍然可以保守地降低,因此此优化仅在前端在栅栏指令上发出 MMRA 元数据时才适用。

其他主题

注意

以下部分仅供参考。

性能影响

MMRA 是一种捕获程序中优化机会的方法。但是,当操作未提及任何标签或标签冲突时,目标可能需要生成保守的代码以确保正确性,而代价是性能下降。这可能发生在以下情况下

  1. 当目标首次引入 MMRA 时,前端可能尚未更新以发出它们。

  2. 优化可能会删除 MMRA 元数据。

  3. 优化可能会向操作添加任意标签。

请注意,目标始终可以选择忽略(甚至删除)MMRA 并恢复到默认行为/代码生成启发式方法,而不会影响正确性。

缺少先于发生的后果

先于发生部分,我们定义了如何通过利用 MMRA 之间的兼容性来打破两个指令之间的先于发生关系。当指令不兼容且不存在先于发生关系时,我们说这些指令“不必彼此排序”。

在此上下文中,“排序”是一个非常广泛的术语,涵盖了静态和运行时方面。

当没有排序约束时,如果重新排序不会破坏其他约束(如单位置一致性),我们可以在优化器转换中静态地重新排序指令。静态重新排序是打破先于发生关系的结果之一,但不是最有趣的结果。

运行时后果更有趣。当指令之间存在先于发生关系时,目标必须发出同步代码以确保其他线程会按正确的顺序观察指令的效果。

例如,目标可能必须等待先前的加载和存储完成才能开始栅栏发布,或者可能需要在执行下一条指令之前刷新内存缓存。在没有先于发生的情况下,没有这样的要求,也不需要等待或刷新。这在某些情况下可能会显着加快执行速度。

组合操作

如果某个过程可以将多个内存或同步操作组合成一个,则它需要能够组合 MMRA。实现此目的的一种可能方法是对标签集执行前缀方式的并集。

令 A 和 B 为两个标签集,U 为 A 和 B 的前缀方式的并集。对于 A 或 B 中存在的每个唯一标签前缀 P

  • 如果 A 或 B 中没有带有前缀 P 的标签,则不会将带有前缀 P 的标签添加到 U 中。

  • 如果 A 和 B 都至少有一个带有前缀 P 的标签,则将两个集合中所有带有前缀 P 的标签都添加到 U 中。

过程应避免积极地组合 MMRA,因为这可能导致大量信息丢失。虽然这不会影响正确性,但可能会影响性能。

作为一般经验法则,诸如 SimplifyCFG 之类的积极组合/重新排序操作的通用过程应该只组合具有相同标签集的指令。不经常组合或非常了解组合 MMRA 成本的过程可以使用上面描述的前缀方式的并集。

示例

A: store release %ptr1  # foo:x, foo:y, bar:x
B: store release %ptr2  # foo:x, bar:y

# Unique prefixes P = [foo, bar]
# "foo:x" is common to A and B so it's added to U.
# "bar:x" != "bar:y" so it's not added to U.
U: store release %ptr3  # foo:x
A: store release %ptr1  # foo:x, foo:y
B: store release %ptr2  # foo:x, bux:y

# Unique prefixes P = [foo, bux]
# "foo:x" is common to A and B so it's added to U.
# No tags have the prefix "bux" in A.
U: store release %ptr3  # foo:x
A: store release %ptr1
B: store release %ptr2  # foo:x, bar:y

# Unique prefixes P = [foo, bar]
# No tags with "foo" or "bar" in A, so no tags added.
U: store release %ptr3