英特尔处理器的 TLB ASID 标签中有多少位?以及如何处理“ASID 溢出”?

Posted

技术标签:

【中文标题】英特尔处理器的 TLB ASID 标签中有多少位?以及如何处理“ASID 溢出”?【英文标题】:How many bits there are in a TLB ASID tag for Intel processors? And how to handle 'ASID overflow'? 【发布时间】:2019-03-19 16:12:07 【问题描述】:

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

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

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

【问题讨论】:

我确实指出这实际上是一个硬件问题,而不是一个编程问题。 TLB 的操作对程序员来说基本上是透明的。 @user3344003 是的,我同意你的看法。但是我应该在哪里问这个问题?同时,操作系统如何处理这个问题也与这个社区密切相关。 您对处理器设计或操作系统如何与处理器交互感兴趣? @user3344003 完全正确,我确实在 *** 中发现了一些关于这个主题的问题 【参考方案1】:

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

我将讨论 Linux 内核如何分配 PCID。 Linux 内核本身实际上甚至对 Intel 处理器也使用了术语 ASID,因此我也将使用这个术语。

一般来说,管理 ASID 空间的方法有很多,如下所示:

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

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

Linux 只记住每个内核上使用的最后 6 个 ASID。这是由TLB_NR_DYN_ASIDS 宏指定的。系统为每个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 空间:

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

每个进程都有一个规范的 ASID。这是Linux本身使用的值。每个规范 ASID 都与一个 kPCID 和一个 uPCID 相关联,它们是实际存储在 CR3.PCID 中的值。每个进程有两个 ASID 的原因是为了支持减轻 Meltdown 漏洞的页表隔离 (PTI)。实际上,使用 PTI,每个进程都有两个虚拟地址空间,每个都有自己的 ASID,但是这两个 ASID 有一个固定的算术关系,如上图所示。因此,尽管英特尔处理器支持每个内核 4096 个 ASID,但 Linux 每个内核只使用 12 个。我将到达ctxs 数组,请耐心等待。

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

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

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

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

第一个参数next指向调度程序选择恢复的线程所属进程的内存描述符。这个对象包含很多东西。但我们在这里关心的一件事是ctx_id,它是一个 64 位值,每个现有进程都是唯一的。 next_tlb_gen 用于确定是否需要 TLB 失效,稍后我将讨论。该函数返回new_asid 保存分配给进程的ASID 和need_flush 表示是否需要TLB 失效。函数的返回类型为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 没有被回收,我们也可能需要刷新与 ASID 关联的 TLB 条目的原因是由于惰性 TLB 失效机制,这超出了您的问题范围。

如果当前使用的 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_asid返回switch_mm_irqs_off时,ctxs数组和CR3也会相应更新。写入 CR3 将使内核自动刷新与该 ASID 关联的 TLB 条目。如果其 ASID 被重新分配给另一个进程的进程仍然处于活动状态,那么下次它的一个线程运行时,它将在该核心上分配一个新的 ASID。这整个过程发生在每个核心上。否则,如果该进程已死,那么在未来的某个时间点,它的 ASID 将被回收。

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

【讨论】:

非常感谢您的详细回答! 今天在 x86 上 ASID 是否映射到 PCID?从我上次检查开始,不清楚将 ASID 映射到 PCID 是否会带来性能优势,因为 IPI 的数量会增加很多:lkml.iu.edu/hypermail/linux/kernel/1504.3/02961.html

以上是关于英特尔处理器的 TLB ASID 标签中有多少位?以及如何处理“ASID 溢出”?的主要内容,如果未能解决你的问题,请参考以下文章

Linux是否为TLB使用x86 CPU的PCID功能?如果没有,为什么?

rust配置

为啥英特尔微处理器的 64 位模式不支持 MOV AH,1?

Haswell 内核一次可以执行多少个 32 位整数运算?

linux kernel 内存管理-页表、TLB

英特尔一共出了多少种CPU品牌?