包装内在函数的简单 C++ 表达式模板会产生不同的指令
Simple C++ expression templates wrapping intrinsics produces different instructions
我正在测试一个非常简单的程序,它使用 C++ 表达式模板来简化对值数组进行操作的 SSE2 和 AVX 代码的编写。
我有一个 class svec
代表值数组。
我有一个 class sreg
代表一个 SSE2 双寄存器。
我有 expr
和 add_expr
表示添加 svec
个数组。
与手动代码相比,编译器为我的表达式模板测试用例在每个循环中额外生成三个指令。我想知道这是否有原因,或者我可以进行任何更改以使编译器产生相同的输出?
完整的测试工具是:
#include <iostream>
#include <emmintrin.h>
struct sreg
{
__m128d reg_;
sreg() {}
sreg(const __m128d& r) :
reg_(r)
{
}
sreg operator+(const sreg& b) const
{
return _mm_add_pd(reg_, b.reg_);
}
};
template <typename T>
struct expr
{
sreg operator[](std::size_t i) const
{
return static_cast<const T&>(*this).operator[](i);
}
operator const T&() const
{
return static_cast<const T&>(*this);
}
};
template <typename A, typename B>
struct add_expr : public expr<add_expr<A, B>>
{
const A& a_;
const B& b_;
add_expr(const A& a, const B& b) :
a_{ a }, b_{ b }
{
}
sreg operator[](std::size_t i) const
{
return a_[i] + b_[i];
}
};
template <typename A, typename B>
inline auto operator+(const expr<A>& a, const expr<B>& b)
{
return add_expr<A, B>(a, b);
}
struct svec : public expr<svec>
{
sreg* regs_;
std::size_t size_;
svec(std::size_t size) :
size_{ size }
{
regs_ = static_cast<sreg*>(_aligned_malloc(size * 32, 32));
}
~svec()
{
_aligned_free(regs_);
}
template <typename T>
svec& operator=(const T& expression)
{
for (std::size_t i = 0; i < size(); i++)
{
regs_[i] = expression[i];
}
return *this;
}
const sreg& operator[](std::size_t index) const
{
return regs_[index];
}
sreg& operator[](std::size_t index)
{
return regs_[index];
}
std::size_t size() const
{
return size_;
}
};
static constexpr std::size_t size = 64;
int main()
{
svec a(size);
svec b(size);
svec c(size);
svec d(size);
svec vec(size);
//hand rolled loop
for (std::size_t j = 0; j < size; j++)
{
vec[j] = a[j] + b[j] + c[j] + d[j];
}
//expression templates version of hand rolled loop
vec = a + b + c + d;
std::cout << "Done...";
std::getchar();
return EXIT_SUCCESS;
}
对于手卷循环,说明是:
00007FF621CD1B70 mov r8,qword ptr [c]
00007FF621CD1B75 mov rdx,qword ptr [b]
00007FF621CD1B7A mov rax,qword ptr [a]
00007FF621CD1B7F vmovupd xmm0,xmmword ptr [rcx+rax]
00007FF621CD1B84 vaddpd xmm1,xmm0,xmmword ptr [rdx+rcx]
00007FF621CD1B89 vaddpd xmm3,xmm1,xmmword ptr [r8+rcx]
00007FF621CD1B8F lea rax,[rcx+rbx]
00007FF621CD1B93 vaddpd xmm1,xmm3,xmmword ptr [r10+rax]
00007FF621CD1B99 vmovupd xmmword ptr [rax],xmm1
00007FF621CD1B9D add rcx,10h
00007FF621CD1BA1 cmp rcx,400h
00007FF621CD1BA8 jb main+0C0h (07FF621CD1B70h)
对于表达式模板版本:
00007FF621CD1BC0 mov rdx,qword ptr [c]
00007FF621CD1BC5 mov rcx,qword ptr [rcx]
00007FF621CD1BC8 mov rax,qword ptr [r8]
00007FF621CD1BCB vmovupd xmm0,xmmword ptr [r9+rax]
00007FF621CD1BD1 vaddpd xmm1,xmm0,xmmword ptr [rcx+r9]
00007FF621CD1BD7 vaddpd xmm0,xmm1,xmmword ptr [rdx+r9]
00007FF621CD1BDD lea rax,[r9+rbx]
00007FF621CD1BE1 vaddpd xmm0,xmm0,xmmword ptr [rax+r10]
00007FF621CD1BE7 vmovupd xmmword ptr [rax],xmm0
00007FF621CD1BEB add r9,10h
00007FF621CD1BEF cmp r9,400h
00007FF621CD1BF6 jae main+154h (07FF621CD1C04h) # extra instruction 1
00007FF621CD1BF8 mov rcx,qword ptr [rsp+60h] # extra instruction 2
00007FF621CD1BFD mov r8,qword ptr [rsp+58h] # extra instruction 3
00007FF621CD1C02 jmp main+110h (07FF621CD1BC0h)
请注意,这是专门演示问题的最少可验证代码。代码是使用 Visual Studio 2015 Update 3.
中的默认发布构建设置编译的
我打折的想法:
循环的顺序(我已经切换了手动循环和表达式模板循环来检查编译器是否仍然插入额外的指令并且确实如此)
编译器正在根据 constexpr
size
优化手动循环(我已经尝试过阻止编译器推断 size
的测试代码常数以更好地优化手动循环,并且对手动循环的指令没有影响。
这两个循环似乎在每次迭代时都重新加载数组指针。 (例如,第一个循环中的 mov r8, [c]
)。第二个版本只是效率更低,有两个间接级别。其中之一出现在循环的末尾,在条件分支跳出循环之后。
请注意,您未将其标识为 "new" 的更改指令之一是 mov rcx, [rcx]
。寄存器分配在循环之间是不同的,但那些是数组起始指针。它(以及存储后的 rcx,[rsp+60h]
)正在替换 mov rax,qword ptr [a]
。我假设 a
也是相对于 RSP 的偏移量,实际上并不是静态存储的标签。
大概是因为 MSVC++ 没有成功进行别名分析来证明 vec[j]
中的存储不能修改任何指针。我没有仔细查看您的模板,但如果您引入了您希望优化掉的额外间接级别,问题是它不是。
显而易见的解决方案是使用优化更好的编译器。 clang3.9 做得很好(自动矢量化,无需重新加载指针),gcc 将其完全优化,因为结果未被使用。
但是如果您坚持使用 MSVC,请查看是否有任何严格别名选项或无别名关键字或声明,它们会有所帮助。例如GNU C++ 扩展包括 __restrict__
以获得与 C99 的 restrict
关键字相同的 "this doesn't alias" 行为。 IDK 如果 MSVC 支持类似的东西。
吹毛求疵:
将jae
称为"extra" 指令不太正确。它只是 jb
的相反谓词,所以现在它是一个 while(true){ ... if() break; reload; }
循环而不是更高效的 do{...}while()
循环。 (我使用 C 语法来显示 asm 循环结构。显然,如果您实际编译了那些 C 循环,编译器可以优化它们。)所以如果有的话,"extra instruction" 是无条件分支,JMP。
对于偶然发现此问题的任何其他人,这是一个 MSVC 可以优化的无锯齿版本,而不会出现我所看到的问题。我不得不使用一些模板元编程来阻止运算符过载过于贪婪。不知道有没有更简单的方法...
#include <iostream>
#include <utility>
#include <type_traits>
#include <emmintrin.h>
class sreg
{
using reg_type = __m128d;
public:
reg_type reg_;
sreg() {}
sreg(const reg_type& r) :
reg_(r)
{
}
sreg operator+(const sreg& b) const
{
return _mm_add_pd(reg_, b.reg_);
}
};
struct expr
{
};
template <typename... Ts>
struct meta_or : std::false_type
{
};
template <typename T, typename... Ts>
struct meta_or<T, Ts...> : std::integral_constant<bool, T::value || meta_or<Ts...>::value>
{
};
template <class... T>
using meta_is_expr = meta_or<std::is_base_of<expr, std::decay_t<T>>..., std::is_base_of<expr, T>...>;
template <class... T>
using meta_enable_if_expr = std::enable_if_t<meta_is_expr<T...>::value>;
template <typename A, typename B>
struct add_expr : public expr
{
A a_;
B b_;
add_expr(A&& a, B&& b) :
a_{ std::forward<A>(a) }, b_{ std::forward<B>(b) }
{
}
sreg operator[](std::size_t i) const
{
return a_[i] + b_[i];
}
};
template <typename A, typename B, typename = meta_enable_if_expr<A, B>>
inline auto operator+(A&& a, B&& b)
{
return add_expr<A, B>{ std::forward<A>(a), std::forward<B>(b) };
}
struct svec : public expr
{
sreg* regs_;;
std::size_t size_;
svec(std::size_t size) :
size_{ size }
{
regs_ = static_cast<sreg*>(_aligned_malloc(size * 32, 32));
}
~svec()
{
_aligned_free(regs_);
}
template <typename T>
svec& operator=(const T& expression)
{
for (std::size_t i = 0; i < size(); i++)
{
regs_[i] = expression[i];
}
return *this;
}
const sreg& operator[](std::size_t index) const
{
return regs_[index];
}
sreg& operator[](std::size_t index)
{
return regs_[index];
}
std::size_t size() const
{
return size_;
}
};
static constexpr std::size_t size = 64;
int main()
{
svec a(size);
svec b(size);
svec c(size);
svec d(size);
svec vec(size);
//hand rolled loop
for (std::size_t j = 0; j < size; j++)
{
vec[j] = a[j] + b[j] + c[j] + d[j];
}
//expression templates version of hand rolled loop
vec = a + b + c + d;
std::cout << "Done...";
std::getchar();
return EXIT_SUCCESS;
}
非常感谢@Peter Cordes 提供的正确线索,他要求提供一些有关 "expression" 工作原理的信息。
对于我们的svec
,单循环发生在赋值运算符中:
template <typename T>
svec& operator=(const T& expression)
{
for (std::size_t i = 0; i < size(); i++)
{
regs_[i] = expression[i];
}
return *this;
}
运算符重载:
template <typename A, typename B, typename = meta_enable_if_expr<A>>
inline auto operator+(A&& a, B&& b)
{
return add_expr<A, B>{ std::forward<A>(a), std::forward<B>(b) };
}
负责强制编译器为我们建立一个表达式树。通过在 sreg
上重载 + 运算符并循环遍历我们的数据,就好像它是 sreg
的数组一样,编译器会将我们的表达式作为运算符内联在我们的 sreg
内部包装器上,表示 __m128d
.
表达式 expr
的每个特化都是 sreg
上的一种函子。我刚刚实施了 expr_add
用于测试目的。
我正在测试一个非常简单的程序,它使用 C++ 表达式模板来简化对值数组进行操作的 SSE2 和 AVX 代码的编写。
我有一个 class svec
代表值数组。
我有一个 class sreg
代表一个 SSE2 双寄存器。
我有 expr
和 add_expr
表示添加 svec
个数组。
与手动代码相比,编译器为我的表达式模板测试用例在每个循环中额外生成三个指令。我想知道这是否有原因,或者我可以进行任何更改以使编译器产生相同的输出?
完整的测试工具是:
#include <iostream>
#include <emmintrin.h>
struct sreg
{
__m128d reg_;
sreg() {}
sreg(const __m128d& r) :
reg_(r)
{
}
sreg operator+(const sreg& b) const
{
return _mm_add_pd(reg_, b.reg_);
}
};
template <typename T>
struct expr
{
sreg operator[](std::size_t i) const
{
return static_cast<const T&>(*this).operator[](i);
}
operator const T&() const
{
return static_cast<const T&>(*this);
}
};
template <typename A, typename B>
struct add_expr : public expr<add_expr<A, B>>
{
const A& a_;
const B& b_;
add_expr(const A& a, const B& b) :
a_{ a }, b_{ b }
{
}
sreg operator[](std::size_t i) const
{
return a_[i] + b_[i];
}
};
template <typename A, typename B>
inline auto operator+(const expr<A>& a, const expr<B>& b)
{
return add_expr<A, B>(a, b);
}
struct svec : public expr<svec>
{
sreg* regs_;
std::size_t size_;
svec(std::size_t size) :
size_{ size }
{
regs_ = static_cast<sreg*>(_aligned_malloc(size * 32, 32));
}
~svec()
{
_aligned_free(regs_);
}
template <typename T>
svec& operator=(const T& expression)
{
for (std::size_t i = 0; i < size(); i++)
{
regs_[i] = expression[i];
}
return *this;
}
const sreg& operator[](std::size_t index) const
{
return regs_[index];
}
sreg& operator[](std::size_t index)
{
return regs_[index];
}
std::size_t size() const
{
return size_;
}
};
static constexpr std::size_t size = 64;
int main()
{
svec a(size);
svec b(size);
svec c(size);
svec d(size);
svec vec(size);
//hand rolled loop
for (std::size_t j = 0; j < size; j++)
{
vec[j] = a[j] + b[j] + c[j] + d[j];
}
//expression templates version of hand rolled loop
vec = a + b + c + d;
std::cout << "Done...";
std::getchar();
return EXIT_SUCCESS;
}
对于手卷循环,说明是:
00007FF621CD1B70 mov r8,qword ptr [c]
00007FF621CD1B75 mov rdx,qword ptr [b]
00007FF621CD1B7A mov rax,qword ptr [a]
00007FF621CD1B7F vmovupd xmm0,xmmword ptr [rcx+rax]
00007FF621CD1B84 vaddpd xmm1,xmm0,xmmword ptr [rdx+rcx]
00007FF621CD1B89 vaddpd xmm3,xmm1,xmmword ptr [r8+rcx]
00007FF621CD1B8F lea rax,[rcx+rbx]
00007FF621CD1B93 vaddpd xmm1,xmm3,xmmword ptr [r10+rax]
00007FF621CD1B99 vmovupd xmmword ptr [rax],xmm1
00007FF621CD1B9D add rcx,10h
00007FF621CD1BA1 cmp rcx,400h
00007FF621CD1BA8 jb main+0C0h (07FF621CD1B70h)
对于表达式模板版本:
00007FF621CD1BC0 mov rdx,qword ptr [c]
00007FF621CD1BC5 mov rcx,qword ptr [rcx]
00007FF621CD1BC8 mov rax,qword ptr [r8]
00007FF621CD1BCB vmovupd xmm0,xmmword ptr [r9+rax]
00007FF621CD1BD1 vaddpd xmm1,xmm0,xmmword ptr [rcx+r9]
00007FF621CD1BD7 vaddpd xmm0,xmm1,xmmword ptr [rdx+r9]
00007FF621CD1BDD lea rax,[r9+rbx]
00007FF621CD1BE1 vaddpd xmm0,xmm0,xmmword ptr [rax+r10]
00007FF621CD1BE7 vmovupd xmmword ptr [rax],xmm0
00007FF621CD1BEB add r9,10h
00007FF621CD1BEF cmp r9,400h
00007FF621CD1BF6 jae main+154h (07FF621CD1C04h) # extra instruction 1
00007FF621CD1BF8 mov rcx,qword ptr [rsp+60h] # extra instruction 2
00007FF621CD1BFD mov r8,qword ptr [rsp+58h] # extra instruction 3
00007FF621CD1C02 jmp main+110h (07FF621CD1BC0h)
请注意,这是专门演示问题的最少可验证代码。代码是使用 Visual Studio 2015 Update 3.
中的默认发布构建设置编译的我打折的想法:
循环的顺序(我已经切换了手动循环和表达式模板循环来检查编译器是否仍然插入额外的指令并且确实如此)
编译器正在根据
constexpr
size
优化手动循环(我已经尝试过阻止编译器推断size
的测试代码常数以更好地优化手动循环,并且对手动循环的指令没有影响。
这两个循环似乎在每次迭代时都重新加载数组指针。 (例如,第一个循环中的 mov r8, [c]
)。第二个版本只是效率更低,有两个间接级别。其中之一出现在循环的末尾,在条件分支跳出循环之后。
请注意,您未将其标识为 "new" 的更改指令之一是 mov rcx, [rcx]
。寄存器分配在循环之间是不同的,但那些是数组起始指针。它(以及存储后的 rcx,[rsp+60h]
)正在替换 mov rax,qword ptr [a]
。我假设 a
也是相对于 RSP 的偏移量,实际上并不是静态存储的标签。
大概是因为 MSVC++ 没有成功进行别名分析来证明 vec[j]
中的存储不能修改任何指针。我没有仔细查看您的模板,但如果您引入了您希望优化掉的额外间接级别,问题是它不是。
显而易见的解决方案是使用优化更好的编译器。 clang3.9 做得很好(自动矢量化,无需重新加载指针),gcc 将其完全优化,因为结果未被使用。
但是如果您坚持使用 MSVC,请查看是否有任何严格别名选项或无别名关键字或声明,它们会有所帮助。例如GNU C++ 扩展包括 __restrict__
以获得与 C99 的 restrict
关键字相同的 "this doesn't alias" 行为。 IDK 如果 MSVC 支持类似的东西。
吹毛求疵:
将jae
称为"extra" 指令不太正确。它只是 jb
的相反谓词,所以现在它是一个 while(true){ ... if() break; reload; }
循环而不是更高效的 do{...}while()
循环。 (我使用 C 语法来显示 asm 循环结构。显然,如果您实际编译了那些 C 循环,编译器可以优化它们。)所以如果有的话,"extra instruction" 是无条件分支,JMP。
对于偶然发现此问题的任何其他人,这是一个 MSVC 可以优化的无锯齿版本,而不会出现我所看到的问题。我不得不使用一些模板元编程来阻止运算符过载过于贪婪。不知道有没有更简单的方法...
#include <iostream>
#include <utility>
#include <type_traits>
#include <emmintrin.h>
class sreg
{
using reg_type = __m128d;
public:
reg_type reg_;
sreg() {}
sreg(const reg_type& r) :
reg_(r)
{
}
sreg operator+(const sreg& b) const
{
return _mm_add_pd(reg_, b.reg_);
}
};
struct expr
{
};
template <typename... Ts>
struct meta_or : std::false_type
{
};
template <typename T, typename... Ts>
struct meta_or<T, Ts...> : std::integral_constant<bool, T::value || meta_or<Ts...>::value>
{
};
template <class... T>
using meta_is_expr = meta_or<std::is_base_of<expr, std::decay_t<T>>..., std::is_base_of<expr, T>...>;
template <class... T>
using meta_enable_if_expr = std::enable_if_t<meta_is_expr<T...>::value>;
template <typename A, typename B>
struct add_expr : public expr
{
A a_;
B b_;
add_expr(A&& a, B&& b) :
a_{ std::forward<A>(a) }, b_{ std::forward<B>(b) }
{
}
sreg operator[](std::size_t i) const
{
return a_[i] + b_[i];
}
};
template <typename A, typename B, typename = meta_enable_if_expr<A, B>>
inline auto operator+(A&& a, B&& b)
{
return add_expr<A, B>{ std::forward<A>(a), std::forward<B>(b) };
}
struct svec : public expr
{
sreg* regs_;;
std::size_t size_;
svec(std::size_t size) :
size_{ size }
{
regs_ = static_cast<sreg*>(_aligned_malloc(size * 32, 32));
}
~svec()
{
_aligned_free(regs_);
}
template <typename T>
svec& operator=(const T& expression)
{
for (std::size_t i = 0; i < size(); i++)
{
regs_[i] = expression[i];
}
return *this;
}
const sreg& operator[](std::size_t index) const
{
return regs_[index];
}
sreg& operator[](std::size_t index)
{
return regs_[index];
}
std::size_t size() const
{
return size_;
}
};
static constexpr std::size_t size = 64;
int main()
{
svec a(size);
svec b(size);
svec c(size);
svec d(size);
svec vec(size);
//hand rolled loop
for (std::size_t j = 0; j < size; j++)
{
vec[j] = a[j] + b[j] + c[j] + d[j];
}
//expression templates version of hand rolled loop
vec = a + b + c + d;
std::cout << "Done...";
std::getchar();
return EXIT_SUCCESS;
}
非常感谢@Peter Cordes 提供的正确线索,他要求提供一些有关 "expression" 工作原理的信息。
对于我们的svec
,单循环发生在赋值运算符中:
template <typename T>
svec& operator=(const T& expression)
{
for (std::size_t i = 0; i < size(); i++)
{
regs_[i] = expression[i];
}
return *this;
}
运算符重载:
template <typename A, typename B, typename = meta_enable_if_expr<A>>
inline auto operator+(A&& a, B&& b)
{
return add_expr<A, B>{ std::forward<A>(a), std::forward<B>(b) };
}
负责强制编译器为我们建立一个表达式树。通过在 sreg
上重载 + 运算符并循环遍历我们的数据,就好像它是 sreg
的数组一样,编译器会将我们的表达式作为运算符内联在我们的 sreg
内部包装器上,表示 __m128d
.
表达式 expr
的每个特化都是 sreg
上的一种函子。我刚刚实施了 expr_add
用于测试目的。