英特尔处理器的 TLB ASID 标签中有多少位?以及如何处理'ASID overflow'?

How many bits there are in a TLB ASID tag for Intel processors? And how to handle 'ASID overflow'?

根据一些操作系统教科书,为了更快的上下文切换,人们在 TLB 标记字段中为每个进程添加 ASID,因此我们不需要在上下文切换时刷新整个 TLB。

听说有些ARM处理器和MIPS处理器在TLB中确实有ASID。但是我不确定Intel x86处理器有没有ASID。

同时,ASID 的位数(例如 8 位)似乎通常少于 PID(32 位)。那么,如果我们在内存中的进程比上面提到的 8 位 ASID 情况下的 2^8 多,系统如何处理 "ASID overflow"?

英特尔调用 ASID 进程上下文标识符 (PCID)。在所有支持 PCID 的英特尔处理器上,PCID 的大小都是 12 位。它们构成了 CR3 寄存器的 11:0 位。默认情况下,在处理器复位时,CR4.PCIDE(CR4 的第 17 位)被清除并且 CR3.PCID 为零,因此如果 OS 想要使用 PCID,则必须设置 CR4.PCIDE 首先启用该功能。仅当设置 CR4.PCIDE 时才允许写入大于零的 PCID 值。也就是说,当设置 CR4.PCIDE 时,也可以将零写入 CR3.PCID。因此,最大可同时使用的PCID数量为2^12 = 4096.

我将讨论 Linux 内核如何分配 PCID。 Linux 内核本身实际上使用术语 ASIDs,即使是对 Intel 处理器也是如此,所以我也将使用这个术语。

总的来说,管理 ASID space 的方法确实有很多,例如:

  • 当需要创建新进程时,为进程分配一个专用的ASID。如果 ASID space 已经用完,则拒绝创建进程并失败。这样简单高效,但可能会严重限制进程数。
  • 当 ASID space 已用尽时,不是将进程数限制为 ASID 的可用性,而是表现得好像不支持 ASID。也就是说,在所有进程的进程上下文切换上刷新整个 TLB。实际上,这是一种糟糕的方法,因为您可能最终会在创建和终止进程时在禁用和启用 ASID 之间切换。此方法会导致潜在的高性能损失。
  • 允许多个进程使用相同的 ASID。在这种情况下,在使用相同 ASID 的进程之间切换时需要小心,因为标记有该 ASID 的 TLB 条目仍然需要刷新。
  • 在前面的所有方法中,每个进程都有一个ASID,因此代表进程的OS数据结构需要有一个存储ASID的字段。另一种方法是将当前分配的 ASID 存储在单独的结构中。 ASID 在进程需要执行时动态分配给进程。不活动的进程不会分配给它们 ASID。与以前的方法相比,这有两个优点。首先,ASID space 的使用效率更高,因为大多数休眠进程不会不必要地使用 ASID。其次,所有当前分配的 ASID 都存储在相同的数据结构中,该数据结构可以做得足够小以适合几个缓存行。这样就可以高效地寻找新的ASID。

Linux 使用最后一种方法,我将更详细地讨论它。

Linux 只记住每个核心上使用的最后 6 个 ASID。这是由定义数组的 TLB_NR_DYN_ASIDS macro. The system creates a data structure for each core of type tlb_state 指定的,如下所示:

struct tlb_context {
    u64 ctx_id;
    u64 tlb_gen;
};

struct tlb_state {

    .
    .
    .

    u16 next_asid;
    struct tlb_context ctxs[TLB_NR_DYN_ASIDS];
};
DECLARE_PER_CPU_SHARED_ALIGNED(struct tlb_state, cpu_tlbstate);

该类型包括其他字段,但为简洁起见,我只显示了两个。 Linux 定义以下 ASID spaces:

  • 规范 ASID space:这些包括 ASID 0 到 6 (TLB_NR_DYN_ASIDS)。这些值存储在 next_asid 字段中并用作 ctxs 数组的索引。
  • 内核 ASID (kPCID) space:这些包括 ASID 1 到 7 (TLB_NR_DYN_ASIDS + 1)。这些值实际上存储在 CR3.PCID.
  • 用户 ASID (uPCID) space:这些包括 ASID 2048 + 1 到 2048 + 7 (2048 + TLB_NR_DYN_ASIDS + 1)。这些值实际上存储在 CR3.PCID.

每个进程都有一个规范的 ASID。这是 Linux 本身使用的值。每个规范的 ASID 都与一个 kPCID 和一个 uPCID 相关联,它们是实际存储在 CR3.PCID 中的值。每个进程有两个 ASID 的原因是为了支持 page-table 隔离 (PTI),它可以缓解 Meltdown 漏洞。其实有了PTI,每个进程都有两个虚拟地址space,每个都有自己的ASID,但是这两个ASID有固定的算术关系,如上图。因此,即使英特尔处理器支持每个内核 4096 个 ASID,Linux 每个内核仅使用 12 个。我会进入 ctxs 数组,请耐心等待。

Linux 在上下文切换时而不是在创建时动态地将 ASID 分配给进程。同一进程可能在不同的内核上获得不同的 ASID,并且只要该进程的线程被调度到内核上的 运行,其 ASID 可能会动态更改。这是在 switch_mm_irqs_off 函数中完成的,每当调度程序在核心上从一个线程切换到另一个线程时都会调用该函数,即使这两个线程属于同一进程。有两种情况需要考虑:

  • 用户线程被中断或执行了系统调用。在这种情况下,系统切换到内核模式来处理中断或系统调用。由于用户线程刚刚 运行ning,它的进程必须有一个已经分配的 ASID。如果 OS 稍后决定恢复执行同一个线程或同一个进程的另一个线程,那么它将继续使用同一个 ASID。这个案子很无聊。
  • OS 决定将另一个进程的线程调度到核心上的 运行 。所以 OS 必须为进程分配一个 ASID。这个案例很有趣,将在本回答的其余部分详细讨论。

在这种情况下,内核执行以下函数调用:

choose_new_asid(next, next_tlb_gen, &new_asid, &need_flush);

第一个参数next指向调度程序选择恢复的线程所属进程的内存描述符。这个对象包含很多东西。但是我们在这里关心的一件事是 ctx_id,这是一个 64 位值,每个现有进程都是唯一的。 next_tlb_gen 用于确定是否需要 TLB 失效,我将在稍后讨论。函数 returns new_asid 保存分配给进程的 ASID 和 need_flush 表示是否需要 TLB 失效。 return函数的类型是void.

static void choose_new_asid(struct mm_struct *next, u64 next_tlb_gen,
                u16 *new_asid, bool *need_flush)
{
    u16 asid;

    if (!static_cpu_has(X86_FEATURE_PCID)) {
        *new_asid = 0;
        *need_flush = true;
        return;
    }

    if (this_cpu_read(cpu_tlbstate.invalidate_other))
        clear_asid_other();

    for (asid = 0; asid < TLB_NR_DYN_ASIDS; asid++) {
        if (this_cpu_read(cpu_tlbstate.ctxs[asid].ctx_id) !=
            next->context.ctx_id)
            continue;

        *new_asid = asid;
        *need_flush = (this_cpu_read(cpu_tlbstate.ctxs[asid].tlb_gen) <
                   next_tlb_gen);
        return;
    }

    /*
     * We don't currently own an ASID slot on this CPU.
     * Allocate a slot.
     */
    *new_asid = this_cpu_add_return(cpu_tlbstate.next_asid, 1) - 1;
    if (*new_asid >= TLB_NR_DYN_ASIDS) {
        *new_asid = 0;
        this_cpu_write(cpu_tlbstate.next_asid, 1);
    }
    *need_flush = true;
}

从逻辑上讲,函数的工作原理如下。如果处理器不支持 PCID,则所有进程的 ASID 值为零,并且始终需要 TLB 刷新。我将跳过 invalidate_other 检查,因为它不相关。接下来,循环遍历所有 6 个规范 ASID,并将它们用作 ctxs 中的索引。上下文标识符为 cpu_tlbstate.ctxs[asid].ctx_id 的进程当前分配了 ASID 值 asid。所以循环检查进程是否仍然有分配给它的 ASID。在这种情况下,使用相同的 ASID 并根据 next_tlb_gen 更新 need_flush。我们可能需要刷新与 ASID 关联的 TLB 条目的原因,即使 ASID 没有被回收,是由于惰性 TLB 失效机制,这超出了您的问题范围。

如果当前使用的 none 个 ASID 已分配给该进程,那么我们需要分配一个新的。对 this_cpu_add_return 的调用只是将 next_asid 中的值增加 1。这给了我们一个 kPCID 值。然后当减去 1 时,我们得到规范的 ASID。如果我们已经超过了最大规范 ASID 值 (TLB_NR_DYN_ASIDS),那么我们回绕到规范 ASID 零并将相应的 kPCID(即 1)写入 next_asid。发生这种情况时,意味着其他一些进程被分配了相同的规范 ASID,因此我们肯定要刷新与核心上该 ASID 关联的 TLB 条目。然后当choose_new_asidreturns到switch_mm_irqs_off时,ctxs数组和CR3相应更新。写入 CR3 将使内核自动刷新与该 ASID 关联的 TLB 条目。如果其 ASID 被重新分配给另一个进程的进程仍然存在,那么下次它的一个线程 运行 时,它将在该核心上分配一个新的 ASID。这整个过程发生在每个核心上。否则,如果该进程死了,那么在未来的某个时候,它的 ASID 将被回收。

Linux 每个内核恰好使用 6 个 ASID 的原因是它使 tlb_state 类型的大小刚好足以容纳两个 64 字节缓存行。通常,在 Linux 系统上可以同时存在数十个进程。但是,它们中的大多数通常处于休眠状态。所以 Linux 管理 ASID space 的方式实际上非常有效。尽管看到关于 TLB_NR_DYN_ASIDS 的值对性能的影响的实验评估会很有趣。但我不知道有任何此类已发表的研究。