아래처럼 다양한 형식의 2차원 백터에 대해 덧셈 연산을 하기 위한 아래 함수들이 준비되어 있었는데, 매번 자료형에 맞춰서 함수명을 바꿔가며 코드를 적는게 뭐랄까... 덜 세련되어 보여 vectormath::add()라는 함수 하나로 오버로딩하려 마음먹은 적이 최근 있었다.
이 과정에서 있었던 동료와의 논의가 나름 재미있어서 옮겨봄.
namespace vectormath {
template <typename uv1, typename uv2>
constexpr auto uvAdd(const uv1& vec1, const uv2& vec2) noexcept
return uv1(vec1.u + vec2.u, vec1.v + vec2.v);
}
template <typename xy1, typename xy2>
constexpr auto xyAdd(const xy1& vec1, const xy2& vec2) noexcept {
return xy1(vec1.x + vec2.x, vec1.y + vec2.y);
}
template <typename XY1, typename XY2>
constexpr auto XYAdd(const XY1& vec1, const XY2& vec2) noexcept {
return XY1(vec1.X + vec2.X, vec1.Y + vec2.Y);
}
template <typename Component>
constexpr auto vec2Add(const Component vec0[2], const Component vec1[2], Component vec2[2]) noexcept {
vec2[0] = vec0[0] + vec1[0];
vec2[1] = vec0[1] + vec1[1];
}
}
concept)처음엔 SFINAE를 사용하면 어찌어찌 되지 않을까... 라는 생각을 가졌다. 멤버 함수에 대해서만이라면 아래처럼 적용하면 되니까.
template <typename xy> requires std::is_fundamental_v<decltype(xy::x)>
xy add(const xy& p0, const xy& p1) noexcept {
return xy(p0.x + p1.x, p0.y + p1.y);
}
template <typename XY> requires std::is_fundamental_v<decltype(XY::X)>
XY add(const XY& p0, const XY& p1) noexcept {
return xy(p0.X + p1.X, p0.Y + p1.Y);
}
template <typename uv> requires std::is_fundamental_v<decltype(uv::u)>
uv add(const uv& p0, const uv& p1) noexcept {
return uv(p0.u + p1.u, p0.v + p1.v);
}
std::index_sequence여기까진 쉬웠고, 배열 형태의 데이터에 대해 어떻게 하면 쉽게 확장할까... 하고 있던 차에 동료가 PR 커멘트로 std::index_sequence를 사용하는 방법을 제안해주었다.
template <size_t Dimension, typename Component, size_t... AxesIndices>
void vecAdd_impl(const Component vec0[Dimension], const Component vec1[Dimension],
Component vec2[Dimension], std::index_sequence<AxesIndices...>) noexcept {
for (size_t axisIndex : { AxesIndices... }) {
vec2[axisIndex] = vec0[axisIndex] + vec1[axisIndex];
}
}
template <size_t Dimension, typename Component>
void add(const Component vec0[Dimension], const Component vec1[Dimension],
Component vec2[Dimension]) noexcept {
vecAdd_impl<Dimension>(vec0, vec1, vec2, std::make_index_sequence<Dimension>());
}
int main() {
double vec2[3][2] = {
{ 1, 1 },
{ 2, 2 },
{ 0, 0 } };
double vec3[3][3] = {
{ 1, 1, 1 },
{ 2, 2, 2 },
{ 0, 0, 0 } };
add<2>(vec2[0], vec2[1], vec2[2]);
add<3>(vec3[0], vec3[1], vec3[2]);
return 0;
}
모르던 초식이라 신박하긴 한데... 왠지 이런 초 간단한 함수에 range-based for loop를, 그것도 braced-init-list 까지 사용하면 왠지 unroll되지 않을 것도 같다는 염려에 아래처럼 (소싯적에 안드레이 알렉산드레스쿠 형님이 Modern C++ Design에서 날 피토하게 하셨던) template recursion을 적용해보면 어떻게냐고 했고...
template <size_t Dimension, typename Component>
struct addImpl {
addImpl(const Component* vec0, const Component* vec1, Component* vec2) noexcept {
vec2[Dimension] = vec0[Dimension] + vec1[Dimension];
addImpl<Dimension - 1, Component>(vec0, vec1, vec2);
}
};
template <typename Component>
struct addImpl<0, Component> {
addImpl(const Component* vec0, const Component* vec1, Component* vec2) noexcept {
vec2[0] = vec0[0] + vec1[0];
}
};
template <size_t Dimension, typename Component>
void add(const Component* vec0, const Component* vec1, Component* vec2) noexcept {
addImpl<Dimension - 1, Component>(vec0, vec1, vec2);
}
비록 컴파일 타임이지만 재귀호출이 마음에 안들어 이쯤 되면 그냥 아래처럼 풀어서 하는 건 어떠냐고 재차 제안했었다.
template <size_t Dimension, typename Component>
struct addImpl {};
template <typename Component>
struct addImpl<3, Component> {
addImpl(const Component* vec0, const Component* vec1, Component* vec2) noexcept {
vec2[0] = vec0[0] + vec1[0];
vec2[1] = vec0[1] + vec1[1];
vec2[2] = vec0[2] + vec1[2];
}
};
template <typename Component>
struct addImpl<2, Component> {
addImpl(const Component* vec0, const Component* vec1, Component* vec2) noexcept {
vec2[0] = vec0[0] + vec1[0];
vec2[1] = vec0[1] + vec1[1];
}
};
if constexpr그 동료분이 다시 제안한 마지막 방법이 가장 나이스하더라... 이것 역시 신박하게도 if constexpr을 사용하는 방법. 요즘 나오는 책 좀 사서 문법 공부를 다시해야겠다는 생각이 든다...ㅎㅎ
template <size_t Dimension, typename Component>
void add(const Component* vec0, const Component* vec1, Component* vec2) noexcept {
if constexpr (Dimension == 0) {
vec2[0] = vec0[0] + vec1[0];
} else {
vec2[Dimension - 1] = vec0[Dimension - 1] + vec1[Dimension - 1];
add<Dimension - 1, Component>(vec0, vec1, vec2);
}
}
결과적으로 SFINAE로 구현한 세 함수와, 마지만 if constexpr 버전을 모아 아래처럼 add함수 하나로 오버로딩이 가능해졌다. (마지막 네 줄)
template <typename Component>
struct Point2 {
Coordinate x;
Coordinate y;
};
template <typename Component>
struct POINT2 {
Coordinate X;
Coordinate X;
};
template <typename Component>
struct TexCoord2 {
Coordinate u;
Coordinate v;
};
void myfunc() {
Point2<float> p0(1, 2), p1(3, 4);
POINT2<int64_t> P0(1, 2), P1(3, 4);
TexCoord2<double> uv0(0.1, 0.2), uv1(0.3, 0.4);
int vec0[2] = { 1, 2 }, vec1[2] = { 3, 4 }, vec[2];
auto p = vectormath::add(p0, p1);
auto P = vectormath::add(P0, P1);
auto uv = vectormath::add(uv0, uv1);
vectormath::add<2>(vec0, vec1, vec);
}
마지막으로 위에 나열했던 함수들이 정말 loop unroll이 되는지를 확인해봤다. 아래 예제처럼 동일한 목적을 수행하는 add0, add1, add2, add3 네 개의 함수를 작성해서 disassembled code를 살펴보는 방식으로.
(어셈블리는 까막눈이지만 뭐 눈치껏...)
template <typename Component>
void add0(size_t n, const Component* vec0, const Component* vec1, Component* vec2) noexcept {
for (size_t i = 0; i != n; ++i) {
vec2[i] = vec0[i] + vec1[i];
}
}
template <size_t dimension, typename Component, size_t... axesIndices>
void add1_impl(const Component vec0[dimension], const Component vec1[dimension], Component vec2[dimension], std::index_sequence<axesIndices...>) noexcept {
for (size_t axisIndex : { axesIndices... }) {
vec2[axisIndex] = vec0[axisIndex] + vec1[axisIndex];
}
}
template <size_t dimension, typename Component>
void add1(const Component vec0[dimension], const Component vec1[dimension], Component vec2[dimension]) noexcept {
add1_impl<dimension>(vec0, vec1, vec2, std::make_index_sequence<dimension>());
}
template <size_t Dimension, typename Component>
void add2(const Component* vec0, const Component* vec1, Component* vec2) noexcept {
if constexpr (Dimension == 1) {
vec2[0] = vec0[0] + vec1[0];
} else {
vec2[Dimension - 1] = vec0[Dimension - 1] + vec1[Dimension - 1];
add2<Dimension - 1, Component>(vec0, vec1, vec2);
}
}
template <size_t Dimension, typename Component>
void add3(const Component vec0[3], const Component vec1[3], Component vec2[3]) noexcept {
vec2[0] = vec0[0] + vec1[0];
vec2[1] = vec0[1] + vec1[1];
vec2[2] = vec0[2] + vec1[2];
}
아래는 add0(). 의도적으로 unroll 되지 않는 케이스를 만들어본 것이다. inlining을 억제하려 n을 int n; sscanf("3", "%d", &n);와 같은 방식으로 초기화한 것인데 했더니 코드가 많이 지저분하더라. (호출부로 돌아가는 것 포함 conditional jump가 두 개...)
add0(n, vec[0], vec[1], vec0);
00007FF62E935780 movups xmm1,xmmword ptr [rbp+rax*8+108h]
00007FF62E935788 movups xmm0,xmmword ptr [rbp+rax*8+0F0h]
00007FF62E935790 addpd xmm1,xmm0
00007FF62E935794 movups xmmword ptr [rbp+rax*8+0D8h],xmm1
00007FF62E93579C movups xmm2,xmmword ptr [rbp+rax*8+118h]
00007FF62E9357A4 movups xmm0,xmmword ptr [rbp+rax*8+100h]
00007FF62E9357AC addpd xmm2,xmm0
00007FF62E9357B0 movups xmmword ptr [rbp+rax*8+0E8h],xmm2
00007FF62E9357B8 movups xmm1,xmmword ptr [rbp+rax*8+128h]
00007FF62E9357C0 movups xmm0,xmmword ptr [rbp+rax*8+110h]
00007FF62E9357C8 addpd xmm1,xmm0
00007FF62E9357CC movups xmmword ptr [rbp+rax*8+0F8h],xmm1
00007FF62E9357D4 movups xmm2,xmmword ptr [rbp+rax*8+120h]
00007FF62E9357DC movups xmm0,xmmword ptr [rbp+rax*8+138h]
00007FF62E9357E4 addpd xmm2,xmm0
00007FF62E9357E8 movups xmmword ptr [rbp+rax*8+108h],xmm2
00007FF62E9357F0 add rax,8
00007FF62E9357F4 cmp rax,rdx
00007FF62E9357F7 jne TestBody+100h (07FF62E935780h)
00007FF62E9357F9 cmp rax,rcx
00007FF62E9357FC je TestBody+1A3h (07FF62E935823h)
참고로, add0()에서 sscanf()를 사용하지 않고 int n = 3;으로 주면 아래처럼 루프가 unroll된다.
int n = 3;
add0(n, vec[0], vec[1], vec0);
00007FF7F6B45729 movsd xmm6,mmword ptr [__real@4022000000000000 (07FF7F6F52F90h)]
00007FF7F6B45731 movaps xmm3,xmm6
00007FF7F6B45734 movq r9,xmm6
00007FF7F6B45739 movsd xmm7,mmword ptr [__real@401c000000000000 (07FF7F6F52F70h)]
00007FF7F6B45741 movaps xmm2,xmm7
00007FF7F6B45744 movq r8,xmm7
00007FF7F6B45749 movsd xmm8,mmword ptr [__real@4014000000000000 (07FF7F6F52F50h)]
00007FF7F6B45752 movaps xmm1,xmm8
00007FF7F6B45756 movq rdx,xmm1
00007FF7F6B4575B lea rcx,[__xmm@00000000000000003fe0000000000000+898h (07FF7F7029B98h)]
다음은 std::index_sequence를 사용한 add1()이다. add0()에 비해 코드는 간소화되었지만 완전히 unroll되지는 않아서 conditional jump가 존재한다.
add1<3>(vec[0], vec[1], vec1);
00007FF62E935856 mov qword ptr [rbp+0D8h],r14
00007FF62E93585D mov qword ptr [rbp+0E0h],1
00007FF62E935868 mov qword ptr [rbp+0E8h],2
00007FF62E935873 lea rcx,[rbp+0D8h]
00007FF62E93587A nop word ptr [rax+rax]
00007FF62E935880 mov rax,qword ptr [rcx]
00007FF62E935883 movsd xmm0,mmword ptr [rbp+rax*8+108h]
00007FF62E93588C addsd xmm0,mmword ptr [rbp+rax*8+0F0h]
00007FF62E935895 movsd mmword ptr [rbp+rax*8+1E0h],xmm0
00007FF62E93589E add rcx,8
00007FF62E9358A2 lea rax,[rbp+0F0h]
00007FF62E9358A9 cmp rcx,rax
00007FF62E9358AC jne TestBody+200h (07FF62E935880h)
if constexpr을 사용한 add2()와 직접 루프를 풀어서 적은 add3()는 사실상 동일한 코드를 생성한다. 재미있는 것은 add2()의 경우 recursion 과정에서 생성되는 코드와 disassembled code가 역순이라는 것이다. 컴파일러가 최적화 과정에서 memory ordering (instruction reordering)을 적용했는가보다...
add2<3>(vec[0], vec[1], vec2);
00007FF62E9358E1 movsd xmm3,mmword ptr [__real@4022000000000000 (07FF62ED42F90h)]
00007FF62E9358E9 movq r9,xmm3
00007FF62E9358EE movsd xmm2,mmword ptr [__real@401c000000000000 (07FF62ED42F70h)]
00007FF62E9358F6 movq r8,xmm2
00007FF62E9358FB movsd xmm1,mmword ptr [__real@4014000000000000 (07FF62ED42F50h)]
00007FF62E935903 movq rdx,xmm1
00007FF62E935908 lea rcx,[__xmm@00000000000000003fe0000000000000+910h (07FF62EE19C10h)]
add3<3>(vec[0], vec[1], vec3);
00007FF62E935914 movsd xmm3,mmword ptr [__real@4022000000000000 (07FF62ED42F90h)]
00007FF62E93591C movq r9,xmm3
00007FF62E935921 movsd xmm2,mmword ptr [__real@401c000000000000 (07FF62ED42F70h)]
00007FF62E935929 movq r8,xmm2
00007FF62E93592E movsd xmm1,mmword ptr [__real@4014000000000000 (07FF62ED42F50h)]
00007FF62E935936 movq rdx,xmm1
00007FF62E93593B lea rcx,[__xmm@00000000000000003fe0000000000000+928h (07FF62EE19C28h)]
결론, 최적화 수준과 사용성 면에서 if constexpr을 사용한 마지막 버전 add2가 가장 낫더라...
template <size_t Dimension, typename Component>
void add2(const Component* vec0, const Component* vec1, Component* vec2) {
if constexpr (Dimension == 1) {
vec2[0] = vec0[0] + vec1[0];
} else {
vec2[Dimension - 1] = vec0[Dimension - 1] + vec1[Dimension - 1];
add2<Dimension - 1, Component>(vec0, vec1, vec2);
}
}