上下文切换的开销是什么?

36

最初我认为上下文切换的开销是TLB被刷新,但我在维基百科上看到:

http://en.wikipedia.org/wiki/Translation_lookaside_buffer

2008年,英特尔(Nehalem)[18]和AMD(SVM)[19]都引入了标记作为TLB条目的一部分,并专门设计了硬件来检查查找期间的标记。尽管这些标记没有得到充分利用,但预计在未来,这些标记将标识每个TLB条目所属的地址空间。因此,上下文切换不会导致TLB被刷新 - 只需将当前地址空间的标记更改为新任务的地址空间的标记。

上述内容是否证实了对于更新的英特尔CPU,在上下文切换时TLB不会被刷新?

这是否意味着现在在上下文切换中没有真正的开销了?

(我试图理解上下文切换的性能惩罚)


2
TLB影响只是上下文切换开销方程式中的一部分。这个开销永远无法完全消除,但像上面的引用所描述的架构变化可以帮助减轻开销。关于这个开销并没有一个标准答案,因为它高度依赖于所使用的硬件、操作系统的确切版本、内核中配置选项、编译器和用于构建内核的优化级别,以及其他很多因素... - twalberg
3
你最好的选择可能是选择你感兴趣的操作系统(例如Linux),查看涉及上下文切换的源代码,包括至少以下4个方面:1)调度决策(下一个运行什么?),2)需要进行哪些VM、TLB和其他缓存结构的调整以进行切换,3)需要保存/加载哪些数据(寄存器、浮点状态等),4)上述任何内容是否需要广播到其他CPU(例如TLB清除等)。这并不是一个简单的主题... - twalberg
5
在上下文切换之后,新进程很可能会在一个非常冷的处理器缓存中运行。这一点单独就足以轻松超过一个冷TLB的成本。 - cmaster - reinstate monica
谢谢大家!你们能给我一些操作系统的概念,让我了解关于TLB方面的知识吗?所以你说“调度”?那么我可以查阅一下操作系统如何调度进程?还有其他的东西吗? - user997112
1
考虑到TLB非常小(Core2上为16,Core i7上为64),这可能并没有太大的改进。如果另一个进程在其时间片内只访问几十KB的内存,无论是否标记,您的TLB都会完全消失。 - Damon
显示剩余4条评论
4个回答

24

正如维基百科在其上下文切换文章中所述,"上下文切换是存储和恢复进程状态(上下文)的过程,以便稍后可以从同一点恢复执行。"。我假设上下文切换发生在同一操作系统内的两个进程之间,而不是用户/内核模式转换(系统调用),后者速度更快且不需要TLB清除。

因此,操作系统内核需要大量时间将当前正在运行的进程的执行状态(真正的所有寄存器和许多特殊控制结构)保存到内存中,然后加载其他进程的执行状态(从内存中读取)。如果需要TLB清除,它会增加一些开销,但这只是总体开销的一小部分。

如果您想找到上下文切换延迟,可以使用lmbench基准测试工具http://www.bitmover.com/lmbench/进行LAT_CTX测试http://www.bitmover.com/lmbench/lat_ctx.8.html

我找不到关于 Nehalem 的结果(Phoronix 套件中是否有 lmbench?),但对于 core2 和现代 Linux,上下文切换可能需要 5-7 微秒。
还有一些低质量测试的结果http://blog.tsunanet.net/2010/11/how-long-does-it-take-to-make-context.html,上下文切换需要 1-3 微秒。无法从他的结果中得出未清除 TLB 的确切影响。 更新 - 你的问题应该是关于虚拟化,而不是进程上下文切换。
RWT在他们关于Nehalem的文章中表示:“内部Nehalem:英特尔未来的处理器和系统。TLBs,页面表和同步”(作者为David Kanter,2008年4月2日),Nehalem添加了VPID到TLB中,以使虚拟机/主机切换(vmentry/vmexit)更快:Nehalem的TLB条目也通过引入“虚拟处理器ID”或VPID而发生了微妙的变化。每个TLB条目缓存虚拟到物理地址转换……该转换特定于给定进程和虚拟机。旧的Intel CPU会在处理器在虚拟化客户机和主机实例之间切换时刷新TLB,以确保进程只能访问它们被允许触及的内存。VPID跟踪TLB中给定翻译条目关联的VM,因此当VM退出和重新进入时,不必为安全起见刷新TLB。… VPID有助于通过降低VM转换的开销来提高虚拟化性能; Intel估计,在Nehalem中,往返VM转换的延迟比Merom(即65nm Core 2)低40%,比45nm Penryn低约三分之一。
此外,您需要知道,在您在问题中引用的片段中,“[18]”链接指向“G. Neiger、A. Santoni、F. Leung、D. Rodgers和R. Uhlig。Intel Virtualization Technology: Hardware Support for Efficient Processor Virtualization。英特尔技术杂志,10(3)。”,因此这是一种有效虚拟化的功能(快速客户机-主机切换)。

维基百科“什么都不知道”;-) - Manuel Selva
1
为什么你说“你的问题应该是关于虚拟化,而不是进程上下文切换”?VPID难道不能帮助常规进程切换吗? - max
1
最大值,引用自维基百科的“Intel虚拟化技术:高效处理器虚拟化的硬件支持”和“AMD(SVM)架构参考手册”中的虚拟化和x86 TLB部分。因此,这是关于虚拟化功能的,它们不应该用于正常的线程/进程切换。如果使用它们,则将禁用将它们用于VM切换。因此,VPID未被使用:http://elixir.free-electrons.com/linux/v4.10/source/arch/x86/entry/entry_32.S#L229 - osgx
@osgx:不,几年前英特尔添加了一个功能(PCID /“进程上下文标识符”),与虚拟化无关(我认为他们不是第一个 - 例如,我怀疑ARM在英特尔之前就有了类似的功能)。 - Brendan

23

让我们将任务切换的成本分为“直接成本”(任务切换代码本身的成本)和“间接成本”(TLB缺失等的成本)。

直接成本

对于直接成本,这主要是保存前一个任务的(面向用户空间的可见架构)状态的成本,然后加载下一个任务的状态。这取决于具体情况,主要是因为它可能包括FPU / MMX / SSE / AVX状态,这些状态可以增加几KB的数据(特别是如果涉及AVX-例如,AVX2本身就是512字节,而AVX-512本身就超过了2 KB)。

请注意,有一种“延迟状态加载”机制,以避免加载(某些或全部)FPU / MMX / SSE / AVX状态的成本,并避免保存未加载的状态;出于性能原因(如果几乎所有任务都使用该状态,则“正在使用该状态需要加载”陷阱/异常的成本超过了尝试在任务切换期间避免执行该操作所节省的成本)或出于安全原因(例如,因为Linux中的代码执行“保存使用的内容”,而不是“保存然后清除使用的内容”,并将属于一个任务的数据留在可以通过漏洞利用攻击获得的寄存器中)。

还有一些其他的成本(更新统计信息 - 如“前一个任务使用的CPU时间量”),确定新任务是否与旧任务使用相同的虚拟地址空间(例如同一进程中的不同线程)等。

间接成本

间接成本实际上是 CPU 所具有的所有“类似缓存”的东西损失的效率,包括缓存本身、TLB、更高级别的分页结构缓存、所有分支预测相关的东西(分支方向、分支目标、返回缓冲区)等等。

间接成本可以分为三种原因。其中一种是由于任务切换完全刷新而发生的间接成本。过去,这主要限于因任务切换期间TLBs被刷新而导致的TLB未命中。请注意,即使使用PCID,也可能会发生这种情况 - ID的数量有4096个限制(当使用“熔断缓解”时,ID成对使用 - 对于每个虚拟地址空间,一个用于用户空间,另一个用于内核),这意味着当使用超过4096(或2048)个虚拟地址空间时,内核必须回收先前使用的ID并刷新所有TLB以便重新使用ID。然而,现在(由于所有的漏洞问题),内核可能会刷新其他内容(例如分支预测内容),以便信息无法从一个任务泄漏到另一个任务,但我真的不知道Linux是否支持这种“类似缓存的”内容(我怀疑他们主要试图防止数据从内核泄漏到用户空间,并出于偶然而防止数据从一个任务泄漏到另一个任务)。

间接成本的另一个原因是容量限制。例如,如果L2缓存只能缓存最多256 KiB的数据,并且先前的任务使用了超过256 KiB的数据; 那么L2缓存将充满对下一个任务无用的数据,并且所有下一个任务想要缓存的数据(之前已经缓存)都将因“最近最少使用”而被逐出。这适用于所有“类似缓存”的东西(包括TLB和更高级别的分页结构缓存,即使使用了PCID功能也是如此)。

间接成本的另一个原因是将任务迁移到不同的CPU。这取决于哪些CPU - 例如,如果将任务迁移到同一核心内的不同逻辑CPU,则许多“类似缓存”的东西可能会由两个CPU共享,迁移成本可能相对较小; 如果将任务迁移到物理套件中的CPU,则两个CPU之间可能没有任何“类似缓存”的东西可以共享,迁移成本可能相对较大。

请注意,间接成本的数量上限取决于任务所执行的操作。例如,如果任务使用大量数据,则间接成本可能相对昂贵(很多缓存和TLB未命中),而如果任务使用微小的数据,则间接成本可能可以忽略不计(很少的缓存和TLB未命中)。

无关

请注意,PCID特性有自己的成本(不仅限于任务切换本身)。具体而言,当一个CPU上的页面翻译被修改时,可能需要使用称为“多CPU TLB清除”的东西在其他CPU上使之失效,这相对昂贵(涉及到IPI /互处理器中断,会干扰其他CPU,每个CPU的成本低至“数百个周期”)。没有PCID,您可以避免其中一些。例如,对于正在单个CPU上运行的单线程进程,如果没有PCID,则知道没有其他CPU可以使用相同的虚拟地址空间,因此知道不需要进行“多CPU TLB清除”,如果多线程进程限制在单个NUMA域中,则只需涉及该NUMA域内的CPU即可进行“多CPU TLB清除”。当使用PCID时,您无法依赖这些技巧,并且存在更高的开销,因为“多CPU TLB清除”并不经常避免。
当然,还有与ID管理相关的一些成本(例如,找出哪个ID可以分配给新创建的任务,当任务终止时撤销ID,当虚拟地址空间比ID更多时重新分配ID的某种“最近未使用”系统等)。
由于这些成本,必然存在病态情况,其中使用PCID的成本超过了由任务切换引起的“较少的TLB未命中”效益(使用PCID会使性能变差)。

8

如果考虑缓存失效(通常应该这样做,因为它是真实世界中上下文切换成本的最大贡献者),由于上下文切换而产生的性能惩罚可能非常巨大:

https://www.usenix.org/legacy/events/expcs07/papers/2-li.pdf(尽管有点过时,但这是我找到的最好的资料)将其范围定在100K-1M CPU周期之间。从理论上讲,在32M L3每个插槽缓存由64字节缓存行组成、完全随机访问以及L3/主RAM的典型访问时间分别为40 cycles / 100 cycles的多插槽服务器盒子的最坏情况下,惩罚可以达到30M+ CPU周期!

根据个人经验,我会说它通常在几万个周期之间,但是根据具体情况,它可能相差一个数量级。


L3缓存是一个共享缓存,旨在在“正常”的多核使用情况下由许多进程同时使用。因此,我无法看出与这种“正常”使用相比,上下文切换会有多糟糕。另一方面,L1和L2缓存,特别是L1,是更私密的缓存,可能会在每次上下文切换时完全清除。 - Benjamin
1
@Benjamin Mar:现在大多数服务器都是多插槽的(主要是2S,但也有相当多的4S),L3不会在插槽之间共享。我添加了一个澄清来反映这一点。 - No-Bugs Hare

-1
注意:正如 Brendan 在他的评论中指出的那样。本答案的目的是回答细节,包括操作系统开销在 Windows vs Linux vs Solaris 等情况下对 Windows 服务器/桌面性能的总体影响。
最好的方法当然是进行基准测试。问题在于每秒上下文切换次数与 CPU 时间之间的关系是指数级别的。换句话说,它是一个 O(n²) 的成本。这意味着我们有一个无法超越的最大限制。
以下基准测试代码使用了一些不安全的变量等...请忽略这一点,因为这不是重点。
每个线程实际执行的工作很少。理论上,每个线程应该生成 1000 次上下文切换/秒。
  • 将以下代码转储到 .NET 控制台应用程序中,并在 Perfmon 中查看结果。
  • 向 Perfmon 添加两个计数器:处理器 ->% 处理器时间系统 ->每秒上下文切换次数。在具有 8 个内核的机器上,128 个线程会产生约 0.1% 的 CPU 开销。

2560个线程应该只占用大约2%的CPU,但实际上在我的Core i7-4790K 4核心+ 4超线程核心台式机上,在2300个线程处CPU占用率达到了100%。

  • 2048个线程-每秒200万次上下文切换:CPU使用率40%
  • 2300个线程-每秒230万次上下文切换:CPU使用率100%

perfmon graph

static void Main(string[] args)
{
    ThreadTestClass ThreadClass;
    bool Wait;
    int Counter;
    Wait = true;
    Counter = 0;
    while (Wait)
    {
        if (Console.KeyAvailable)
        {
            ConsoleKey Key = Console.ReadKey().Key;
            switch (Key)
            {
                case ConsoleKey.UpArrow:
                    ThreadClass = new ThreadTestClass();
                    break;
                case ConsoleKey.DownArrow:
                    SignalExitThread();
                    break;
                case ConsoleKey.PageUp:
                    SleepTime += 1;
                    break;
                case ConsoleKey.PageDown:
                    SleepTime -= 1;
                    break;
                case ConsoleKey.Insert:
                    for (int I = 0; I < 64; I++)
                    {
                        ThreadClass = new ThreadTestClass();
                    }
                    break;
                case ConsoleKey.Delete:
                    for (int I = 0; I < 64; I++)
                    {
                        SignalExitThread();
                    }
                    break;
                case ConsoleKey.Q:
                    Wait = false;
                    break;
                case ConsoleKey.Spacebar:
                    Wait = false;
                    break;
                case ConsoleKey.Enter:
                    Wait = false;
                    break;
            }
        }
        Counter += 1;
        if (Counter >= 10)
        {
            Counter = 0;
            Console.WriteLine(string.Concat(@"Thread Count: ", NumThreadsActive.ToString(), @" - SleepTime: ", SleepTime.ToString(), @" - Counter: ", UnSafeCounter.ToString()));
        }
        System.Threading.Thread.Sleep(100);
    }
    IsActive = false;
}

public static object SyncRoot = new object();
public static bool IsActive = true;
public static int SleepTime = 1;
public static long UnSafeCounter = 0;
private static int m_NumThreadsActive;
public static int NumThreadsActive
{
    get
    {
        lock(SyncRoot)
        {
            return m_NumThreadsActive;
        }
    }
}
private static void NumThreadsActive_Inc()
{
    lock (SyncRoot)
    {
        m_NumThreadsActive += 1;
    }
}
private static void NumThreadsActive_Dec()
{
    lock (SyncRoot)
    {
        m_NumThreadsActive -= 1;
    }
}
private static int ThreadsToExit = 0;
private static bool ThreadExitFlag = false;
public static void SignalExitThread()
{
    lock(SyncRoot)
    {
        ThreadsToExit += 1;
        ThreadExitFlag = (ThreadsToExit > 0);
    }
}

private static bool ExitThread()
{
    if (ThreadExitFlag)
    {
        lock (SyncRoot)
        {
            ThreadsToExit -= 1;
            ThreadExitFlag = (ThreadsToExit > 0);
            return (ThreadsToExit >= 0);
        }
    }
    return false;
}

public class ThreadTestClass
{
    public ThreadTestClass()
    {
        System.Threading.Thread RunThread;
        RunThread = new System.Threading.Thread(new System.Threading.ThreadStart(ThreadRunMethod));
        RunThread.Start();
    }

    public void ThreadRunMethod()
    {
        long Counter1;
        long Counter2;
        long Counter3;
        Counter1 = 0;
        NumThreadsActive_Inc();
        try
        {
            while (IsActive && (!ExitThread()))
            {
                UnSafeCounter += 1;
                System.Threading.Thread.Sleep(SleepTime);
                Counter1 += 1;
                Counter2 = UnSafeCounter;
                Counter3 = Counter1 + Counter2;
            }
        }
        finally
        {
            NumThreadsActive_Dec();
        }
    }
}

这个基准测试将上下文切换的直接成本与调度程序的开销(例如,在上下文切换发生之前确定要切换到哪个线程)和其他开销(例如,管理睡眠队列)混为一谈,并忽略了上下文切换的间接成本。 - Brendan
@Brendan - 非常好的观点,感谢您澄清。我想我会把直接/间接等因素放在一边,从这个角度来看待它:“在Windows服务器(或桌面)上,上下文切换的总体影响是什么?与Linux机器相比,影响完全不同。” - tcwicks
你有检查每秒实际上下文切换次数的计数器吗?你回答中的上下文切换次数是否基于计数器或理论假设?能否更新以澄清?谢谢! - 9ilsdx 9rvj 0lo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接