[英]Understanding volatile asm vs volatile variable
我們考慮以下程序,這只是定時循環:
#include <cstdlib>
std::size_t count(std::size_t n)
{
#ifdef VOLATILEVAR
volatile std::size_t i = 0;
#else
std::size_t i = 0;
#endif
while (i < n) {
#ifdef VOLATILEASM
asm volatile("": : :"memory");
#endif
++i;
}
return i;
}
int main(int argc, char* argv[])
{
return count(argc > 1 ? std::atoll(argv[1]) : 1);
}
為了便於閱讀,具有volatile變量和volatile asm的版本如下:
#include <cstdlib>
std::size_t count(std::size_t n)
{
volatile std::size_t i = 0;
while (i < n) {
asm volatile("": : :"memory");
++i;
}
return i;
}
int main(int argc, char* argv[])
{
return count(argc > 1 ? std::atoll(argv[1]) : 1);
}
使用g++ -Wall -Wextra -g -std=c++11 -O3 loop.cpp -o loop
在g++ 8
下進行編譯的時間大致如下:
default: 0m0.001s
-DVOLATILEASM: 0m1.171s
-DVOLATILEVAR: 0m5.954s
-DVOLATILEVAR -DVOLATILEASM: 0m5.965s
我的問題是:為什么呢? 默認版本是正常的,因為編譯器已對循環進行了優化。 但是我很難理解為什么-DVOLATILEVAR
比-DVOLATILEASM
更長,因為兩者都應強制循環運行。
編譯器資源管理器為-DVOLATILEASM
提供以下count
功能:
count(unsigned long):
mov rax, rdi
test rdi, rdi
je .L2
xor edx, edx
.L3:
add rdx, 1
cmp rax, rdx
jne .L3
.L2:
ret
對於-DVOLATILEVAR
(以及組合的-DVOLATILEASM -DVOLATILEVAR
):
count(unsigned long):
mov QWORD PTR [rsp-8], 0
mov rax, QWORD PTR [rsp-8]
cmp rdi, rax
jbe .L2
.L3:
mov rax, QWORD PTR [rsp-8]
add rax, 1
mov QWORD PTR [rsp-8], rax
mov rax, QWORD PTR [rsp-8]
cmp rax, rdi
jb .L3
.L2:
mov rax, QWORD PTR [rsp-8]
ret
為什么會這樣呢? 為什么變量的volatile
限定條件會阻止編譯器執行與asm volatile
相同的循環?
當您使i
volatile
您告訴編譯器它不知道的某些內容可以更改其值。 這意味着每次使用它時都必須加載它的值,並且每次寫入它時都必須存儲它。 當i
volatile
,編譯器可以優化該同步。
-DVOLATILEVAR
強制編譯器將循環計數器保留在內存中,因此循環瓶頸會導致存儲/重新加載(存儲轉發)的延遲, -DVOLATILEVAR
個周期+ add
1個周期的延遲。
每次對volatile int i
賦值和從volatile int i
讀取的賦值都被認為是優化程序必須在內存中發生的可觀察到的副作用,而不僅僅是寄存器。 這就是volatile
意思。
還需要重新加載以進行比較,但這只是吞吐量問題,而不是延遲問題。 〜6個循環循環帶有數據依賴性,這意味着您的CPU不受任何吞吐量限制的瓶頸。
這與您從-O0
編譯器輸出中獲得的結果相似,因此請看一下我的回答: 添加編譯時的冗余分配可加快代碼的速度,而無需對諸如此類的更多循環以及x86存儲轉發進行優化。
僅使用VOLATILEASM
,空的asm
模板( ""
)必須運行正確的次數。 為空時,它不會向循環添加任何指令,因此您剩下一個2 uop add / cmp + jne循環,該循環可以在現代x86 CPU上以每個時鍾1次迭代的速度運行。
至關重要的是,盡管存在編譯器內存障礙,循環計數器仍可以保留在寄存器中。 "memory"
破壞器被視為對非內聯函數的調用 :它可以讀取或修改它可能引用的任何對象,但不包括從未使用其地址轉義過該函數的局部變量。 (即我們從未調用過sscanf("0", "%d", &i)
或posix_memalign(&i, 64, 1234)
。但是,如果這樣做了,那么"memory"
屏障將不得不溢出/重新加載它,因為外部函數可以保存指向該對象的指針。
即, "memory"
破壞對象只是對可能在當前函數外部可見的對象的完整編譯器屏障。 這實際上只是一個問題,當您四處查看編譯器的輸出以查看哪些障礙可以做什么時,因為障礙僅對其他線程可能指向的變量的多線程正確性很重要。
順便說一句,您的asm
語句已經隱式volatile
因為它沒有輸出操作數。 (請參閱gcc手冊中的Extended-Asm#Volatile )。
您可以添加虛擬輸出以使編譯器可以優化其非易失性asm
語句,但不幸的是, gcc
在從中刪除了非易失性asm語句后仍保持空循環。 如果i
的地址轉義了該函數,則刪除asm語句會完全在函數返回之前將循環變成對存儲的單個比較跳轉。 我認為直接返回而不存儲到該本地是合法的,因為沒有正確的程序可以知道它在i
超出范圍之前設法從另一個線程讀取了i
。
但是無論如何,這是我使用的來源。 正如我說的,請注意,這里總是有一個asm
語句,並且我正在控制它是否volatile
。
#include <stdlib.h>
#include <stdio.h>
#ifndef VOLATILEVAR // compile with -DVOLATILEVAR=volatile to apply that
#define VOLATILEVAR
#endif
#ifndef VOLATILEASM // Different from your def; yours drops the whole asm statement
#define VOLATILEASM
#endif
// note I ported this to also be valid C, but I didn't try -xc to compile as C.
size_t count(size_t n)
{
int dummy; // asm with no outputs is implicitly volatile
VOLATILEVAR size_t i = 0;
sscanf("0", "%zd", &i);
while (i < n) {
asm VOLATILEASM ("nop # operand = %0": "=r"(dummy) : :"memory");
++i;
}
return i;
}
編譯(使用gcc4.9和更高版本的-O3,均未啟用VOLATILE)到該奇怪的asm。 ( 帶有gcc和clang的Godbolt編譯器資源管理器 ):
# gcc8.1 -O3 with sscanf(.., &i) but non-volatile asm
# the asm nop doesn't appear anywhere, but gcc is making clunky code.
.L8:
mov rdx, rax # i, <retval>
.L3: # first iter entry point
lea rax, [rdx+1] # <retval>,
cmp rax, rbx # <retval>, n
jb .L8 #,
干得好,GCC .... gcc4.8 -O3
避免拉一個額外的mov
內循環:
# gcc4.8 -O3 with sscanf(.., &i) but non-volatile asm
.L3:
add rdx, 1 # i,
cmp rbx, rdx # n, i
ja .L3 #,
mov rax, rdx # i.0, i # outside the loop
無論如何,如果沒有偽輸出操作數或帶有volatile
,gcc8.1會給我們:
# gcc8.1 with sscanf(&i) and asm volatile("nop" ::: "memory")
.L3:
nop # operand = eax # dummy
mov rax, QWORD PTR [rsp+8] # tmp96, i
add rax, 1 # <retval>,
mov QWORD PTR [rsp+8], rax # i, <retval>
cmp rax, rbx # <retval>, n
jb .L3 #,
因此,我們看到了循環計數器的相同存儲/重載,只是與volatile i
區別( volatile i
是cmp
不需要重載)。
我使用nop
而不是僅添加注釋,因為Godbolt默認情況下隱藏僅注釋行,我希望看到它。 對於gcc,它純粹是文本替換:我們正在查看編譯器的asm輸出,其中將操作數替換為模板,然后將其發送到匯編器。 對於clang來說,可能會有一些效果,因為asm必須有效(即實際上正確地組裝了)。
如果我們注釋掉scanf
並刪除偽輸出操作數,則會得到其中只有nop
的僅寄存器循環。 但是請保留偽輸出操作數,並且nop
不會出現在任何地方。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.