在现代 x86 PC 上,特别是在像 Linux 这样的非实时操作系统下的用户空间中,没有很好的方法可以从固定计数的延迟循环中获得准确且可预测的时序。(但你可以在rdtsc 上旋转很短的延迟;见下文)。如果您需要至少足够长的睡眠时间,并且在出现问题时可以睡更长的时间,则可以使用简单的延迟循环。
通常您希望休眠并让操作系统唤醒您的进程,但这不适用于 Linux 上只有几微秒的延迟。 nanosleep 可以表达,但是内核并没有安排这么精确的时间。见How to make a thread sleep/block for nanoseconds (or at least milliseconds)?。在启用了 Meltdown + Spectre 缓解的内核上,到内核的往返时间无论如何都超过一微秒。
(或者你是在内核内部这样做吗?我认为 Linux 已经有一个校准延迟循环。无论如何,它有一个标准的延迟 API:https://www.kernel.org/doc/Documentation/timers/timers-howto.txt,包括使用“jiffies”时钟的ndelay(unsigned long nsecs) - 速度估计至少休眠足够长的时间。IDK 准确度如何,或者当时钟速度较低时它有时会比所需的休眠时间长得多,或者它是否会随着 CPU 频率的变化而更新校准。)
在最近的 Intel/AMD CPU 上,您的(内部)循环完全可以预测为每个核心时钟周期 1 次迭代,无论其中是否有 nop。它低于 4 个融合域微指令,因此您的 CPU 的每时钟 1 个循环吞吐量成为瓶颈。 (请参阅 Agner Fog's x86 microarch guide,或使用 perf stat ./a.out 自己为大迭代计数计时。)除非在同一物理内核上存在来自另一个超线程的竞争...
或者,除非内部循环跨越 32 字节边界,否则在 Skylake 或 Kaby Lake 上(微码更新禁用循环缓冲区以解决设计错误)。然后你的dec / jnz 循环可以每 2 个周期运行 1 个,因为它需要从 2 个不同的 uop-cache 行中获取。
我建议省略 nop,以便在更多 CPU 上也有更好的机会使其成为每时钟 1 个。无论如何您都需要对其进行校准,因此更大的代码占用空间没有帮助(因此也不要进行额外的对齐)。 (如果您需要确保最小延迟时间,请确保在 CPU 处于最大加速时进行校准。)
如果您的内部循环不是那么小(例如更多 nops),请参阅 Is performance reduced when executing loops whose uop count is not a multiple of processor width? 了解当 uop 计数不是 8 的倍数时前端吞吐量的详细信息。SKL / KBL 已禁用即使对于微小的循环,循环缓冲区也会从 uop 缓存中运行。
但 x86 没有固定的时钟频率(和 transitions between frequency states stop the clock for ~20k clock cycles (8.5us),在 Skylake CPU 上)。
如果在启用中断的情况下运行此程序,则中断是另一个不可预测的延迟来源。(即使在内核模式下,Linux 通常也启用中断。一个禁用中断的延迟循环用于数万时钟循环似乎是个坏主意。)
如果在用户空间中运行,那么我希望您使用的是带有实时支持编译的内核。但即便如此,Linux 也不是完全为硬实时操作而设计的,所以我不确定你能做到多好。
系统管理模式中断是另一个延迟来源,即使是内核也不知道。根据英特尔的 PC BIOS 测试套件,2013 年的PERFORMANCE IMPLICATIONS OF
SYSTEM MANAGEMENT MODE 表示,对于 SMI 而言,150 微秒被认为是“可接受的”延迟。现代个人电脑充满了巫术。我认为/希望大多数主板上的固件没有太多的 SMM 开销,并且 SMI 在正常操作中非常罕见,但我不确定。另见Evaluating SMI (System Management Interrupt) latency on Linux-CentOS/Intel machine
极低功耗的 Skylake CPU 会以一些占空比停止时钟,而不是降低时钟并持续运行。见this,也见Intel's IDF2015 presentation about Skylake power management。
旋转RDTSC 直到正确的挂钟时间
如果您真的需要等待,请转为 rdtsc 等待当前时间到达最后期限。您需要知道 reference 频率,它与核心时钟无关,因此它是固定且不间断的(在现代 CPU 上;有用于不变和不间断 TSC 的 CPUID 功能位。Linux 会检查这一点,所以您可以在 /proc/cpuinfo 中查找 constant_tsc 和 nonstop_tsc,但实际上您应该在程序启动时自己检查 CPUID 并计算出 RDTSC 频率(不知何故...))。
我写了这样一个循环作为愚蠢的计算机技巧练习的一部分:a stopwatch in the fewest bytes of x86 machine code。大多数代码大小用于字符串操作以增加 00:00:00 显示并打印它。我为我的 CPU 硬编码了 4GHz RDTSC 频率。
对于少于 2^32 个参考时钟的睡眠,您只需要查看计数器的低 32 位。如果您正确地进行比较,则环绕会自行解决。对于 1 秒秒表,4.3GHz CPU 会出现问题,但对于 nsec / usec sleeps 则没有问题。
;;; Untested, NASM syntax
default rel
section .data
; RDTSC frequency in counts per 2^16 nanoseconds
; 3200000000 would be for a 3.2GHz CPU like your i3-3470
ref_freq_fixedpoint: dd 3200000000 * (1<<16) / 1000000000
; The actual integer value is 0x033333
; which represents a fixed-point value of 3.1999969482421875 GHz
; use a different shift count if you like to get more fractional bits.
; I don't think you need 64-bit operand-size
; nanodelay(unsigned nanos /*edi*/)
; x86-64 System-V calling convention
; clobbers EAX, ECX, EDX, and EDI
global nanodelay
nanodelay:
; take the initial clock sample as early as possible.
; ideally even inline rdtsc into the caller so we don't wait for I$ miss.
rdtsc ; edx:eax = current timestamp
mov ecx, eax ; ecx = start
; lea ecx, [rax-30] ; optionally bias the start time to account for overhead. Maybe make this a variable stored with the frequency.
; then calculate edi = ref counts = nsec * ref_freq
imul edi, [ref_freq_fixedpoint] ; counts * 2^16
shr edi, 16 ; actual counts, rounding down
.spinwait: ; do{
pause ; optional but recommended.
rdtsc ; edx:eax = reference cycles since boot
sub eax, ecx ; delta = now - start. This may wrap, but the result is always a correct unsigned 0..n
cmp eax, edi ; } while(delta < sleep_counts)
jb .spinwait
ret
为了避免频率计算使用浮点数,我使用了像uint32_t ref_freq_fixedpoint = 3.2 * (1<<16); 这样的定点数。这意味着我们只在延迟循环内使用整数乘法和移位。 在启动期间使用 C 代码设置 ref_freq_fixedpoint 并为 CPU 设置正确的值。
如果为每个目标 CPU 重新编译它,乘法常数可以是 imul 的立即操作数,而不是从内存中加载。
pause 在 Skylake 上休眠约 100 个时钟,但在以前的 Intel uarches 上仅休眠约 5 个时钟。因此,它会稍微影响时序精度,当 CPU 频率降至 ~1GHz 时,可能会在截止日期后休眠 100 ns。或者以正常的 ~3GHz 速度,更接近 +33ns。
持续运行,这个循环将我的 Skylake i7-6700k 的一个核心在 ~3.9GHz 下加热了 ~15 摄氏度,没有 pause,但只有 ~9 摄氏度 pause。 (使用大型 CoolerMaster Gemini II 热管冷却器,基线温度约为 30C,但机箱中的气流较低,以保持风扇噪音低。)
将开始时间测量值调整为比实际时间更早可以让您补偿一些额外的开销,例如离开循环时的分支错误预测,以及首先rdtsc 直到可能接近其执行结束时才对时钟进行采样。乱序执行可以让rdtsc提前运行;您可以使用lfence,或考虑rdtscp,在调用延迟函数之前阻止第一个时钟样本在指令之前发生乱序。
将偏移量保留在变量中也可以让您校准恒定偏移量。如果您可以在启动时自动执行此操作,则可以很好地处理 CPU 之间的变化。但是你需要一些高精度的计时器才能让它工作,这已经基于rdtsc。
将第一个 RDTSC 内联到调用者中并将低 32 位作为另一个函数 arg 传递将确保“计时器”立即启动,即使在调用延迟函数时存在指令缓存未命中或其他管道停顿。所以 I$ 错过时间将是延迟间隔的一部分,而不是额外的开销。
在rdtsc上旋转的优势:
如果发生任何延迟执行的事情,循环仍会在截止日期前退出,除非在截止日期过去时执行当前被阻塞(在这种情况下,您会被任何方法搞砸)。
因此,您可以使用 CPU 时间,直到当前时间比您第一次检查时晚 n * freq 纳秒,而不是精确使用 n 周期的 CPU 时间。
使用简单的计数器延迟循环,在 4GHz 下足够长的延迟会使您在 0.8GHz 下睡眠时间超过 4 倍(最新 Intel CPU 的典型最低频率)。
这确实会运行rdtsc 两次,因此它不适用于只有几纳秒的延迟。 (rdtsc 本身约为 20 微秒,在 Skylake/Kaby Lake 上每 25 个时钟的吞吐量为 1 个。)我认为对于数百或数千纳秒的忙碌等待来说,这可能是最不坏的解决方案,不过。
缺点:迁移到具有未同步 TSC 的另一个内核可能会导致睡眠时间错误。但除非您的延迟非常长,否则迁移时间会更长比预期的延迟。最坏的情况是在迁移后再次休眠延迟时间。我进行比较的方式:(now - start) < count,而不是寻找某个目标目标计数,这意味着当now-start 是一个很大的数字时,无符号环绕将使比较为真。当柜台环绕时,您不会被困在几乎一秒钟的睡眠中。
缺点:maybe you want to sleep for a certain number of core cycles,或者在 CPU 休眠时暂停计数。
缺点:旧 CPU 可能没有不间断/不变的 TSC。在启动时检查这些 CPUID 功能位,并可能使用备用延迟循环,或者至少在校准时将其考虑在内。另请参阅Get CPU cycle count?,了解我对 RDTSC 行为的规范答案的尝试。
未来的 CPU:在具有 WAITPKG CPUID 功能的 CPU 上使用 tpause。
(我不知道未来哪些 CPU 应该会有这个。)
类似于pause,但将逻辑核心置于休眠状态,直到 TSC = 您在 EDX:EAX 中提供的值。所以你可以rdtsc 找出当前时间,add / adc 睡眠时间缩放到 TSC 刻度到 EDX:EAX,然后运行 tpause。
有趣的是,它需要另一个输入寄存器,您可以在其中放置 0 以获得更深的睡眠(对另一个超线程更友好,可能会退回到单线程模式),或 1 以获得更快的唤醒和更少的功率-保存。
你不会想用它来睡几秒钟;您想将控制权交还给操作系统。但是,如果它很远,您可以进行操作系统睡眠以接近您的目标唤醒,然后在剩下的任何时间都使用mov ecx,1 或xor ecx,ecx / tpause ecx。
半相关(也是 WAITPKG 扩展的一部分)是更有趣的 umonitor / umwait,它(如特权监视器/mwait)可以在看到内存更改时唤醒内核地址范围。对于超时,它在 TSC = EDX:EAX 上的唤醒与 tpause 相同。