std::simd를 쓰면 컴파일 시간이 늘고, 초기 성능이 느리며, 에러 메시지가 길어지고, 코드 가독성과 휴대성에 대한 새로운 관점을 제공한다는 풍자적 이유들을 예제와 벤치마크로 보여준다.
요즘은 우리 일상의 대부분이 TikTok에서 둠스크롤링하는 데 쓰인다고 말해도 거짓말이 아니다. 그런데도 이상하게 매니저들은 그 시간이 “생산적으로” 쓰인다고 믿지 않는다. C++ 표준 위원회는 모든 C++ 개발자의 DX를 개선하기 위해 열심히 일하고 있다. 그들이 떠올린 아이디어의 이름은 긴 컴파일 시간이다. 그러면 PM에게 내가 생산적이라고 말할 수 있고, 코드는 그저 컴파일만 하면 된다.
#include <experimental/simd>
void sin_1(float* a) {
auto x = std::experimental::simd<float>(a, std::experimental::element_aligned_tag{});
x = std::experimental::sin(x);
x.copy_to(a, std::experimental::element_aligned_tag{});
}
$ hyperfine -N "g++ -O3 -ffast-math -march=znver5 reason_1/file_1.cpp -std=c++26 -c"
Benchmark 1: g++ -O3 -ffast-math -march=znver5 reason_1/file_1.cpp -std=c++26 -c
Time (mean ± σ): 1.577 s ± 0.017 s [User: 1.529 s, System: 0.042 s]
Range (min … max): 1.559 s … 1.611 s 10 runs
#include <cstdint>
#include <cmath>
void sin_2(float* a) {
for (uint64_t i = 0; i < 8; i++) {
a[i] = std::sin(a[i]);
}
}
$ hyperfine -N "g++ -O3 -ffast-math -march=znver5 reason_1/file_2.cpp -std=c++26 -c"
Benchmark 1: g++ -O3 -ffast-math -march=znver5 reason_1/file_2.cpp -std=c++26 -c
Time (mean ± σ): 162.9 ms ± 14.0 ms [User: 151.2 ms, System: 10.9 ms]
Range (min … max): 154.9 ms … 200.1 ms 14 runs
std::simd를 쓰면 컴파일 시간이 늘어나는 것이 보장된다. “고작 1초?”라고 생각하겠지만, 믿어라. 코드베이스가 커질수록 컴파일 시간도 함께 커진다.
요즘은 매달 과금을 정당화하려면 투자자와 고객에게 소프트웨어를 지속적으로 개선해야 한다고 설득해야 한다. C++ 표준 위원회는 C++가 현대 소프트웨어 개발 요구사항에 적응하도록 열심히 일하고 있다. 해결책은 아주 간단하다. 처음에는 소프트웨어를 느리게 만들어 두면, 나중에 열심히 일한 성과가 얼마나 큰지 보여주는 멋진 그래프를 그릴 수 있다. std::simd는 미래 최적화를 위한 여유(headroom)를 남기기 위해, 초기에 부머 루프보다 느리도록 특별히 설계되었다.
$ clang++ reason_2/file.cpp -DNDEBUG -std=c++26 -march=native -O3 -ffast-math -lbenchmark -pthread -fveclib=libmvec
------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------
BM_1_mean 5.00 ns 4.99 ns 5
BM_1_median 5.00 ns 4.99 ns 5
BM_1_stddev 0.004 ns 0.004 ns 5
BM_1_cv 0.08 % 0.08 % 5
BM_2_mean 3.78 ns 3.77 ns 5
BM_2_median 3.78 ns 3.77 ns 5
BM_2_stddev 0.015 ns 0.015 ns 5
BM_2_cv 0.40 % 0.40 % 5
C++98이 발표되던 시절의 화면 해상도 발전을 되짚어 보자. 당시 모니터 해상도는 대략 1024x768이었다. 하지만 요즘은 1920x1080이 더 흔하고, 내 노트북은 심지어 4K다. 해상도는 증가했지만 C++ 에러 메시지는 그 속도를 따라가지 못했다. C++ 표준 위원회는 이 문제를 높은 우선순위로 지정했는데, 이 문제가 하드웨어에서 모든 성능을 쥐어짜는 데 엄청난 잠재력을 방치하고 있기 때문이다. std::simd가 이 문제를 해결해 주길 바란다.
#include <experimental/simd>
#include <stdfloat>
void func(std::float16_t *a) {
auto x = std::experimental::simd<std::float16_t>(a, std::experimental::element_aligned_tag{});
auto m = x > std::float16_t(324.324);
std::experimental::where(m, x) = x + x;
x.copy_to(a, std::experimental::element_aligned_tag{});
}
결과:
In file included from /usr/include/c++/15.2.1/experimental/simd:74,
from reason_3/file.cpp:2:
/usr/include/c++/15.2.1/experimental/bits/simd.h: In instantiation of 'constexpr _TW std::experimental::parallelism_v2::__andnot(_TW, _TW) [with _TW = _SimdWrapper<_Float16, 8, void>]':
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:845:28: required from 'static constexpr std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np> std::experimental::parallelism_v2::_CommonImplX86::_S_blend(std::experimental::parallelism_v2::_SimdWrapper<decltype (__int_for_sizeof<sizeof (_Tp)>()), _Np>, std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np>, std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np>) [with _Tp = _Float16; long unsigned int _Np = 8; decltype (__int_for_sizeof<sizeof (_Tp)>()) = short int]'
845 | auto __r = __or(__andnot(__kk, __at0), __and(__kk, __at1));
| ~~~~~~~~^~~~~~~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_builtin.h:2361:33: required from 'static constexpr void std::experimental::parallelism_v2::_SimdImplBuiltin<_Abi, <template-parameter-1-2> >::_S_masked_assign(std::experimental::parallelism_v2::_SimdWrapper<_K, _Np>, std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np>&, std::__type_identity_t<std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np> >) [with _Tp = _Float16; _K = short int; long unsigned int _Np = 8; _Abi = std::experimental::parallelism_v2::simd_abi::_VecBuiltin<16>; <template-parameter-1-2> = std::experimental::parallelism_v2::__detail::_MachineFlagsTemplate<221055, 10>; std::__type_identity_t<std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np> > = std::__type_identity<std::experimental::parallelism_v2::_SimdWrapper<_Float16, 8, void> >::type]'
2361 | __lhs = _CommonImpl::_S_blend(__k, __lhs, __rhs);
| ~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:3654:25: required from 'void std::experimental::parallelism_v2::where_expression<_M, _Tp>::operator=(_Up&&) && [with _Up = std::experimental::parallelism_v2::simd<_Float16>; _M = std::experimental::parallelism_v2::simd_mask<_Float16, std::experimental::parallelism_v2::simd_abi::_VecBuiltin<16> >; _Tp = std::experimental::parallelism_v2::simd<_Float16>]'
3654 | _Impl::_S_masked_assign(__data(_M_k), __data(_M_value),
| ~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3655 | __to_value_type_or_member_type<_Tp>(
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3656 | static_cast<_Up&&>(__x)));
| ~~~~~~~~~~~~~~~~~~~~~~~~~
reason_3/file.cpp:8:42: required from here
8 | std::experimental::where(m, x) = x + x;
| ^
/usr/include/c++/15.2.1/experimental/bits/simd.h:2050:47: error: no match for call to '(const std::experimental::parallelism_v2::<unnamed struct>) (const __vector(8) _Float16&, const __vector(8) _Float16&)'
2050 | const auto __r = _S_x86_andnot(__ai, __bi);
| ~~~~~~~~~~~~~^~~~~~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1973:1: note: there are 9 candidates
1973 | {
| ^
/usr/include/c++/15.2.1/experimental/bits/simd.h:1975:3: note: candidate 1: '__v4sf std::experimental::parallelism_v2::<unnamed struct>::operator()(__v4sf, __v4sf) const'
1975 | operator()(__v4sf __a, __v4sf __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1975:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v4sf'
1975 | operator()(__v4sf __a, __v4sf __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1979:3: note: candidate 2: '__v2df std::experimental::parallelism_v2::<unnamed struct>::operator()(__v2df, __v2df) const'
1979 | operator()(__v2df __a, __v2df __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1979:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v2df'
1979 | operator()(__v2df __a, __v2df __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1983:3: note: candidate 3: '__v2di std::experimental::parallelism_v2::<unnamed struct>::operator()(__v2di, __v2di) const'
1983 | operator()(__v2di __a, __v2di __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1983:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v2di'
1983 | operator()(__v2di __a, __v2di __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1987:3: note: candidate 4: '__v8sf std::experimental::parallelism_v2::<unnamed struct>::operator()(__v8sf, __v8sf) const'
1987 | operator()(__v8sf __a, __v8sf __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1987:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v8sf'
1987 | operator()(__v8sf __a, __v8sf __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1991:3: note: candidate 5: '__v4df std::experimental::parallelism_v2::<unnamed struct>::operator()(__v4df, __v4df) const'
1991 | operator()(__v4df __a, __v4df __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1991:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v4df'
1991 | operator()(__v4df __a, __v4df __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1995:3: note: candidate 6: '__v4di std::experimental::parallelism_v2::<unnamed struct>::operator()(__v4di, __v4di) const'
1995 | operator()(__v4di __a, __v4di __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:1995:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v4di'
1995 | operator()(__v4di __a, __v4di __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:2006:3: note: candidate 7: '__v16sf std::experimental::parallelism_v2::<unnamed struct>::operator()(__v16sf, __v16sf) const'
2006 | operator()(__v16sf __a, __v16sf __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:2006:22: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v16sf'
2006 | operator()(__v16sf __a, __v16sf __b) const noexcept
| ~~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:2017:3: note: candidate 8: '__v8df std::experimental::parallelism_v2::<unnamed struct>::operator()(__v8df, __v8df) const'
2017 | operator()(__v8df __a, __v8df __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:2017:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v8df'
2017 | operator()(__v8df __a, __v8df __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:2028:3: note: candidate 9: '__v8di std::experimental::parallelism_v2::<unnamed struct>::operator()(__v8di, __v8di) const'
2028 | operator()(__v8di __a, __v8di __b) const noexcept
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:2028:21: note: no known conversion for argument 1 from 'const __vector(8) _Float16' to '__v8di'
2028 | operator()(__v8di __a, __v8di __b) const noexcept
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:2054:26: error: no type named 'type' in 'using _TVT = std::conditional_t<true, std::experimental::parallelism_v2::_SimdWrapper<_Float16, 8, void>, std::experimental::parallelism_v2::_VectorTraitsImpl<std::experimental::parallelism_v2::_SimdWrapper<_Float16, 8, void>, void> >' {aka 'using std::conditional<true, std::experimental::parallelism_v2::_SimdWrapper<_Float16, 8, void>, std::experimental::parallelism_v2::_VectorTraitsImpl<std::experimental::parallelism_v2::_SimdWrapper<_Float16, 8, void>, void> >::type = struct std::experimental::parallelism_v2::_SimdWrapper<_Float16, 8, void>'}
2054 | return reinterpret_cast<typename _TVT::type>(__r);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In file included from /usr/include/c++/15.2.1/experimental/simd:80:
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h: In instantiation of 'static _Tp std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin(_Tp, _Tp, _Tp) [with _Tp = __vector(8) _Float16]':
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:863:28: required from 'static constexpr std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np> std::experimental::parallelism_v2::_CommonImplX86::_S_blend(std::experimental::parallelism_v2::_SimdWrapper<decltype (__int_for_sizeof<sizeof (_Tp)>()), _Np>, std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np>, std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np>) [with _Tp = _Float16; long unsigned int _Np = 8; decltype (__int_for_sizeof<sizeof (_Tp)>()) = short int]'
863 | return _S_blend_intrin(__to_intrin(__kk), __to_intrin(__at0),
| ~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
864 | __to_intrin(__at1));
| ~~~~~~~~~~~~~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_builtin.h:2361:33: required from 'static constexpr void std::experimental::parallelism_v2::_SimdImplBuiltin<_Abi, <template-parameter-1-2> >::_S_masked_assign(std::experimental::parallelism_v2::_SimdWrapper<_K, _Np>, std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np>&, std::__type_identity_t<std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np> >) [with _Tp = _Float16; _K = short int; long unsigned int _Np = 8; _Abi = std::experimental::parallelism_v2::simd_abi::_VecBuiltin<16>; <template-parameter-1-2> = std::experimental::parallelism_v2::__detail::_MachineFlagsTemplate<221055, 10>; std::__type_identity_t<std::experimental::parallelism_v2::_SimdWrapper<_Tp, _Np> > = std::__type_identity<std::experimental::parallelism_v2::_SimdWrapper<_Float16, 8, void> >::type]'
2361 | __lhs = _CommonImpl::_S_blend(__k, __lhs, __rhs);
| ~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd.h:3654:25: required from 'void std::experimental::parallelism_v2::where_expression<_M, _Tp>::operator=(_Up&&) && [with _Up = std::experimental::parallelism_v2::simd<_Float16>; _M = std::experimental::parallelism_v2::simd_mask<_Float16, std::experimental::parallelism_v2::simd_abi::_VecBuiltin<16> >; _Tp = std::experimental::parallelism_v2::simd<_Float16>]'
3654 | _Impl::_S_masked_assign(__data(_M_k), __data(_M_value),
| ~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3655 | __to_value_type_or_member_type<_Tp>(
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3656 | static_cast<_Up&&>(__x)));
| ~~~~~~~~~~~~~~~~~~~~~~~~~
reason_3/file.cpp:8:42: required from here
8 | std::experimental::where(m, x) = x + x;
| ^
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:802:20: error: no match for call to '(const std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin<__vector(8) _Float16>(__vector(8) _Float16, __vector(8) _Float16, __vector(8) _Float16)::<unnamed struct>) (__vector(8) _Float16&, __vector(8) _Float16&, __vector(8) _Float16&)'
802 | return __eval(__a, __b, __k);
| ~~~~~~^~~~~~~~~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:758:7: note: there are 6 candidates
758 | {
| ^
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:759:40: note: candidate 1: '__m128 std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin(_Tp, _Tp, _Tp)::<unnamed struct>::operator()(__m128, __m128, __m128) const [with _Tp = __vector(8) _Float16; __m128 = __m128]'
759 | _GLIBCXX_SIMD_INTRINSIC __m128 operator()(__m128 __a, __m128 __b,
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:759:58: note: no known conversion for argument 1 from '__vector(8) _Float16' to '__m128'
759 | _GLIBCXX_SIMD_INTRINSIC __m128 operator()(__m128 __a, __m128 __b,
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:764:41: note: candidate 2: '__m128d std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin(_Tp, _Tp, _Tp)::<unnamed struct>::operator()(__m128d, __m128d, __m128d) const [with _Tp = __vector(8) _Float16; __m128d = __m128d]'
764 | _GLIBCXX_SIMD_INTRINSIC __m128d operator()(__m128d __a, __m128d __b,
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:764:60: note: no known conversion for argument 1 from '__vector(8) _Float16' to '__m128d'
764 | _GLIBCXX_SIMD_INTRINSIC __m128d operator()(__m128d __a, __m128d __b,
| ~~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:769:41: note: candidate 3: '__m128i std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin(_Tp, _Tp, _Tp)::<unnamed struct>::operator()(__m128i, __m128i, __m128i) const [with _Tp = __vector(8) _Float16; __m128i = __m128i]'
769 | _GLIBCXX_SIMD_INTRINSIC __m128i operator()(__m128i __a, __m128i __b,
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:769:60: note: no known conversion for argument 1 from '__vector(8) _Float16' to '__m128i'
769 | _GLIBCXX_SIMD_INTRINSIC __m128i operator()(__m128i __a, __m128i __b,
| ~~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:777:40: note: candidate 4: '__m256 std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin(_Tp, _Tp, _Tp)::<unnamed struct>::operator()(__m256, __m256, __m256) const [with _Tp = __vector(8) _Float16; __m256 = __m256]'
777 | _GLIBCXX_SIMD_INTRINSIC __m256 operator()(__m256 __a, __m256 __b,
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:777:58: note: no known conversion for argument 1 from '__vector(8) _Float16' to '__m256'
777 | _GLIBCXX_SIMD_INTRINSIC __m256 operator()(__m256 __a, __m256 __b,
| ~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:782:41: note: candidate 5: '__m256d std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin(_Tp, _Tp, _Tp)::<unnamed struct>::operator()(__m256d, __m256d, __m256d) const [with _Tp = __vector(8) _Float16; __m256d = __m256d]'
782 | _GLIBCXX_SIMD_INTRINSIC __m256d operator()(__m256d __a, __m256d __b,
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:782:60: note: no known conversion for argument 1 from '__vector(8) _Float16' to '__m256d'
782 | _GLIBCXX_SIMD_INTRINSIC __m256d operator()(__m256d __a, __m256d __b,
| ~~~~~~~~^~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:787:41: note: candidate 6: '__m256i std::experimental::parallelism_v2::_CommonImplX86::_S_blend_intrin(_Tp, _Tp, _Tp)::<unnamed struct>::operator()(__m256i, __m256i, __m256i) const [with _Tp = __vector(8) _Float16; __m256i = __m256i]'
787 | _GLIBCXX_SIMD_INTRINSIC __m256i operator()(__m256i __a, __m256i __b,
| ^~~~~~~~
/usr/include/c++/15.2.1/experimental/bits/simd_x86.h:787:60: note: no known conversion for argument 1 from '__vector(8) _Float16' to '__m256i'
787 | _GLIBCXX_SIMD_INTRINSIC __m256i operator()(__m256i __a, __m256i __b,
| ~~~~~~~~^~~
이 에러는 너무 암호 같아서, 미래 릴리스에서는 C++ 에러 메시지를 포스트-퀀텀 암호화 스킴으로 추가할 가능성까지 열어 준다.
void loop_2(std::vector<int> &a) {
for (auto &e : a) {
e = (e & 1) == 0 ? e + e : e;
}
}
부머 루프를 보면 C++26 이전의 모든 문제를 확인할 수 있다. 날것의 안전하지 않은 메모리 접근이 있고, 독자는 초보자들이 흔히 어려워하는 포인터와 기타 저수준 비트 조작 개념을 이해해야 한다. std::simd는 고성능 코드를 읽기 쉽고 추론하기 쉽게 만드는 것을 목표로 한다. 안타까운 점은, 어째서인지 현대 언어들은 올바른 코드가 “깔끔”해 보이도록 설계하는 데 집중한다는 것이다. 그런데 나는 보통 올바르지 않은 코드를 다룬다. 올바른 코드가 어떻게 생겼는지는 관심 없다. 내가 원하는 건 나쁜 코드를 가능한 한 빨리 식별하는 것이다. 그 결과, 현대 언어들은 겉보기엔 깔끔하지만 직관적이지 않게 동작하고 취약한 코드를 만들어낸다. C++ 표준 위원회는 std::simd를 통해 주류에 맞서, 가독성과 고수준 구성 요소를 이상적인 방식으로 결합한다.
void loop_1(std::vector<int> &a) {
using lanes_t = std::experimental::simd<int>;
const std::size_t N = a.size();
std::size_t i = 0;
for (; i + lanes_t::size() <= N; i += lanes_t::size()) {
lanes_t v(&a[i], std::experimental::element_aligned);
auto even = (v & 1) == 0;
std::experimental::where(even, v) = v + v;
v.copy_to(&a[i], std::experimental::element_aligned);
}
for (; i < N; ++i) {
int a_i = a[i];
a[i] = ((a_i & 1) == 0) ? (a_i + a_i) : a_i;
}
}
$ clang++ reason_4/file.cpp -DNDEBUG -std=c++26 -march=native -O3 -ffast-math -lbenchmark -pthread -fveclib=libmvec
------------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------------
BM_0_mean 44.8 ns 44.7 ns 5
BM_0_median 44.7 ns 44.6 ns 5
BM_0_stddev 0.182 ns 0.175 ns 5
BM_0_cv 0.41 % 0.39 % 5
BM_1_mean 133 ns 133 ns 5
BM_1_median 133 ns 133 ns 5
BM_1_stddev 0.356 ns 0.357 ns 5
BM_1_cv 0.27 % 0.27 % 5
BM_2_mean 83.5 ns 83.4 ns 5
BM_2_median 77.9 ns 77.7 ns 5
BM_2_stddev 7.93 ns 7.92 ns 5
BM_2_cv 9.49 % 9.50 % 5
다시 말하지만, 미래에는 부머 루프와 비교했을 때 std::simd의 최적화 잠재력을 보게 되길 기대한다. 이는 기본 벡터 폭이 실제로 머신 벡터 폭이 아니기 때문이다. 그동안 안티들은 C++가 (대부분 타입 시스템을 통해) 고쳐지길 바랐다. 정렬을 타입의 일부로 만들고, 별칭(aliasing) 동작을 정의하는 제대로 된 시스템을 제공해 고치길 바랐다. 그리고 “int8_t + int8_t = int32_t” 같은 추가 함정까지 고쳐서, X가 올리는 “C/C++ sucks write asm instead” 류의 똥포스트의 주된 원인을 없애 주길 바랐다. 하지만 안티들은 이 동작이 C++에 필수적이라는 걸 단순히 이해하지 못한다. 그렇지 않으면 uint16_t(0xFFFF) * uint16_t(0xFFFF)가 UB가 아니게 되고, 따라서 “unsigned integer overflow does wrap around” 규칙에 예외가 전혀 없어져 C++가 덜 C++처럼 느껴질 것이기 때문이다. 하지만 C++가 언어 수준의 SIMD 제어 흐름 메커니즘을 통해 일급 경험으로서의 제대로 된 SIMD를 내놓길 바라는 안티들에게, 혹은 휴대 가능한 이기종 컴퓨팅(즉, Vulkan SPIRV를 타깃으로)을 일급으로 지원하길 바라는 안티들에게: 알고 보니, 그냥 더 오래 기다리면서 C++ 표준 위원회가 요리하는 걸 지켜보기만 했어야 했다. 그리고 봐라, 얼마나 많이 요리했는지.
이제 좀 더 진지한 톤으로 바꿔야겠다. AI 시대에 우리는 기계가 사람들의 일자리를 빼앗으려 할 때 사람들이 어떤 영향을 받는지 본다. C++에서도 똑같은 일이 벌어진다. 컴파일러가 컴퓨터 대수 시스템이 아니면 뭐겠는가? 다음 예를 보자:
__attribute((noinline)) void f_1(float* a) {
auto x = std::experimental::simd<float>(a, std::experimental::element_aligned_tag{});
x = std::experimental::sqrt(x);
x *= x;
x.copy_to(a, std::experimental::element_aligned_tag{});
}
__attribute((noinline)) void f_2(float* a) {
for (uint64_t i = 0; i < 8; i++) {
float x = a[i];
x = std::sqrt(x);
x *= x;
a[i] = x;
}
}
그리고 reason_5/file.asm의 생성된 어셈블리
_Z3f_1Pf:
vsqrtps xmm0, oword [rdi] ; 0000BB60 _ C5 F8: 51. 07
vmulps xmm0, xmm0, xmm0 ; 0000BB64 _ C5 F8: 59. C0
vmovups oword [rdi], xmm0 ; 0000BB68 _ C5 F8: 11. 07
ret ; 0000BB6C _ C3
_Z3f_2Pf:
ret ; 0000BC20 _ C3
컴파일러는 부머 루프 함수에서 모든 코드를 제거해 버리는데, sqrt가 와 상쇄되기 때문이다. 이 일은 기계가 대신해 버린다. 완벽한 코드를 손수 만들고 싶어 하는 유능한 수학자들이 존재하더라도 말이다. 공감 능력이 없는 사이코패스가 아니라면 여기서 기계에 맞서지 않을 수 없다. 그리고 C++ 표준 위원회도 마찬가지로, 언어 기능보다 라이브러리를 선호함으로써 인류 편에 선다.
대부분의 프로그래머에게 코드를 만들어내는 행위 자체가 기쁨을 준다. 많은 사람이 std::simd가 휴대 가능(portable)해지는 것이 위험하지 않냐고 내게 묻는다. 같은 코드를 여러 번 쓰는 것 => 더 많은 재미, 그런데 한 번만 쓰면 된다 => 더 적은 재미. 많은 사람은 이것이 장기적으로 사기에 영향을 줄까 봐 두려워한다.
Reason 4의 코드를 보자.
Disassembly of section .text:
0000000000000000 <_Z6loop_2RSt6vectorIiSaIiEE>:
0: a9400001 ldp x1, x0, [x0]
4: eb01001f cmp x0, x1
8: 540001e0 b.eq 44 <_Z6loop_2RSt6vectorIiSaIiEE+0x44> // b.none
c: d1001002 sub x2, x0, #0x4
10: d2800000 mov x0, #0x0 // #0
14: cb010042 sub x2, x2, x1
18: d342fc42 lsr x2, x2, #2
1c: 91000442 add x2, x2, #0x1
20: 25a21fe7 whilelo p7.s, xzr, x2
24: a5405c3f ld1w {z31.s}, p7/z, [x1, x0, lsl #2]
28: 04bf03fe add z30.s, z31.s, z31.s
2c: 0580001f and z31.s, z31.s, #0x1
30: 25809fe7 cmpeq p7.s, p7/z, z31.s, #0
34: e5405c3e st1w {z30.s}, p7, [x1, x0, lsl #2]
38: 04b0e3e0 incw x0
3c: 25a21c07 whilelo p7.s, x0, x2
40: 54ffff21 b.ne 24 <_Z6loop_2RSt6vectorIiSaIiEE+0x24> // b.any
44: d65f03c0 ret
0000000000000048 <_Z6loop_1RSt6vectorIiSaIiEE>:
48: a9400c04 ldp x4, x3, [x0]
4c: cb040063 sub x3, x3, x4
50: 9342fc65 asr x5, x3, #2
54: f100307f cmp x3, #0xc
58: 540004c9 b.ls f0 <_Z6loop_1RSt6vectorIiSaIiEE+0xa8> // b.plast
5c: 3dc0009f ldr q31, [x4]
60: d2800202 mov x2, #0x10 // #16
64: d2800101 mov x1, #0x8 // #8
68: d2800086 mov x6, #0x4 // #4
6c: 4ebf1ffe mov v30.16b, v31.16b
70: 4ebf87fd add v29.4s, v31.4s, v31.4s
74: 0580001e and z30.s, z30.s, #0x1
78: 4ea09bde cmeq v30.4s, v30.4s, #0
7c: 6efe1ffd bif v29.16b, v31.16b, v30.16b
80: 3d80009d str q29, [x4]
84: f100707f cmp x3, #0x1c
88: 540001e9 b.ls c4 <_Z6loop_1RSt6vectorIiSaIiEE+0x7c> // b.plast
8c: f9400003 ldr x3, [x0]
90: aa0103e6 mov x6, x1
94: 91001021 add x1, x1, #0x4
98: 8b020064 add x4, x3, x2
9c: 3ce2687c ldr q28, [x3, x2]
a0: 91004042 add x2, x2, #0x10
a4: 4ebc1f9b mov v27.16b, v28.16b
a8: 4ebc879a add v26.4s, v28.4s, v28.4s
ac: 0580001b and z27.s, z27.s, #0x1
b0: 4ea09b7b cmeq v27.4s, v27.4s, #0
b4: 6efb1f9a bif v26.16b, v28.16b, v27.16b
b8: 3d80009a str q26, [x4]
bc: eb0100bf cmp x5, x1
c0: 54fffe62 b.cs 8c <_Z6loop_1RSt6vectorIiSaIiEE+0x44> // b.hs, b.nlast
c4: eb0600bf cmp x5, x6
c8: 54000129 b.ls ec <_Z6loop_1RSt6vectorIiSaIiEE+0xa4> // b.plast
cc: f9400000 ldr x0, [x0]
d0: cb0600a5 sub x5, x5, x6
d4: 25a50fe7 whilelo p7.s, wzr, w5
d8: a5465c19 ld1w {z25.s}, p7/z, [x0, x6, lsl #2]
dc: 04b90338 add z24.s, z25.s, z25.s
e0: 05800019 and z25.s, z25.s, #0x1
e4: 25809f27 cmpeq p7.s, p7/z, z25.s, #0
e8: e5465c18 st1w {z24.s}, p7, [x0, x6, lsl #2]
ec: d65f03c0 ret
f0: d2800006 mov x6, #0x0 // #0
f4: 17fffff4 b c4 <_Z6loop_1RSt6vectorIiSaIiEE+0x7c>
해결책은 간단하다. std::simd는 ARM에서 컴파일된다는 의미에서는 휴대 가능하지만, 여기서 볼 수 있듯이 std::simd의 어셈블리는 메인 루프에서 SVE, 즉 armv9 simd 명령을 사용하지 않는다. 반면 부머 루프는 사용한다. 이것이 std::simd를 쓰는 것이 옛날 부머 루프를 쓰는 것보다 더 재미있는 이유다.
std::simd로 더 많은 것들을 해 보고 싶지만, 아직 지원은 초기 단계다. 즉 많은 문제에서 우리는 부머 루프로 이것저것 만지작거릴 수밖에 없다. 예를 들어 std::simd용 std::complex가 구현될 때까지 기다려야 한다.
void loop_2(std::vector<std::complex<float>> &a) {
for (auto &e : a) {
e *= e;
}
}
.L4:
vmovups zmm1, ZMMWORD PTR [rax]
vmovups zmm2, ZMMWORD PTR [rax+64]
sub rax, -128
vmovaps zmm0, zmm1
vpermt2ps zmm1, zmm5, zmm2
vpermt2ps zmm0, zmm6, zmm2
vmulps zmm2, zmm0, zmm1
vmulps zmm1, zmm1, zmm1
vfmsub132ps zmm0, zmm1, zmm0
vaddps zmm2, zmm2, zmm2
vmovaps zmm1, zmm0
vpermt2ps zmm0, zmm3, zmm2
vpermt2ps zmm1, zmm4, zmm2
vmovups ZMMWORD PTR [rax-64], zmm0
vmovups ZMMWORD PTR [rax-128], zmm1
cmp rdx, rax
jne .L4
하지만 std::simd는 Java, Rust & Go 같은 많은 프로그래밍 언어들이 향하는 방향과 C++를 정렬(alignment)시킨다. std::simd는 하드웨어를 가능한 한 효과적으로 사용하는 것이 핵심이 되는 C++의 미래를 위한 길을 닦는다. std::simd는 각 아키텍처가 깨끗하고 휴대 가능한 내장 연산자 위에, 암호 같은 이름의 비휴대 래퍼를 제각각 두고 있는 문제를 잘 해결한다.
__ai __attribute__((target("fullfp16,neon"))) float16x8_t vaddq_f16(float16x8_t __p0, float16x8_t __p1) {
float16x8_t __ret;
__ret = __p0 + __p1;
return __ret;
}
static __inline __m512d __DEFAULT_FN_ATTRS512
_mm512_add_pd(__m512d __a, __m512d __b)
{
return (__m512d)((__v8df)__a + (__v8df)__b);
}