- Published on
C++23 std - -mdspan로 0복사 배열뷰와 수명안전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 모듈이 같은 수치 데이터를 다룰 때, 가장 흔한 병목은 계산이 아니라 “배열 전달”입니다. std::vector를 복사하거나, 포인터와 차원을 따로 넘기고, 호출자·피호출자 간 수명(lifetime) 계약이 문서에만 존재하는 순간부터 버그가 시작됩니다.
C++23의 std::mdspan은 이 문제를 정면으로 해결합니다. 핵심은 소유하지 않는 다차원 뷰라는 점입니다. 즉, 실제 메모리는 그대로 두고(0복사), 차원·스트라이드·레이아웃을 타입으로 표현해 범용 커널을 만들 수 있습니다. 다만 “소유하지 않는다”는 말은 곧 “수명 안전을 스스로 설계해야 한다”는 뜻이기도 합니다.
이 글에서는 std::mdspan을 사용해 0복사 배열뷰를 구성하는 방법과, 실무에서 가장 자주 터지는 수명 문제를 어떻게 API로 봉인할지(특히 반환·캐싱·비동기 경계)까지 다룹니다.
관련해서 빌드/CI에서 성능·안정성을 챙기는 관점은 GitHub Actions 캐시 무효화로 빌드 느림 해결도 함께 참고하면 좋습니다.
std::mdspan이 해결하는 것
1) 0복사 다차원 인덱싱
기존에는 2차원 배열을 함수에 넘길 때 보통 아래 중 하나였습니다.
T* data, int rows, int cols로 넘기고 인덱스 계산을 수동으로 함std::vector<T>를 통째로 넘기되, 2차원 의미는 주석으로만 유지- 라이브러리 고유 매트릭스 타입으로 강결합
std::mdspan은 data 포인터(혹은 포인터 유사 핸들)와 extents(차원), layout(메모리 배치), accessor(접근 정책) 을 조합해 “다차원 뷰”를 표준화합니다.
2) 레이아웃/스트라이드가 타입으로 드러남
행 우선(row-major)과 열 우선(column-major), 또는 패딩이 낀 스트라이드 배열을 다룰 때, mdspan은 이를 명시적으로 표현합니다. 이는 SIMD/BLAS 연동, 이미지 처리(피치가 있는 버퍼), 타일링 등에서 특히 유리합니다.
3) 수명은 더 명확해지지만, 자동으로 안전해지진 않음
mdspan은 소유하지 않으므로 dangling(댕글링) 위험이 있습니다. 하지만 반대로 말하면, “복사로 얼버무리던 수명 문제”가 API 표면으로 드러납니다. 이제는 반환 금지, 캐싱 금지, 비동기 경계에서 소유 타입으로 승격 같은 규칙을 코드로 강제할 수 있습니다.
기본 사용법: 연속 메모리 + 2D 뷰
아래는 std::vector<float>를 2D 행렬로 0복사 뷰잉하는 가장 기본적인 패턴입니다.
#include <mdspan>
#include <vector>
#include <cstddef>
#include <cassert>
namespace stdx = std; // 일부 구현/문서에서 std::experimental을 쓰기도 했습니다.
using matrix_view = std::mdspan<float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right>;
matrix_view as_matrix(std::vector<float>& buf, std::size_t rows, std::size_t cols) {
assert(buf.size() == rows * cols);
return matrix_view(buf.data(), rows, cols);
}
void add_bias_inplace(matrix_view m, float b) {
for (std::size_t i = 0; i < m.extent(0); ++i) {
for (std::size_t j = 0; j < m.extent(1); ++j) {
m(i, j) += b;
}
}
}
int main() {
std::vector<float> buf(3 * 4, 1.0f);
auto m = as_matrix(buf, 3, 4);
add_bias_inplace(m, 2.0f);
}
여기서 std::layout_right는 C/C++에서 흔한 행 우선(row-major) 배치에 해당합니다. 반대로 열 우선이 필요하면 std::layout_left를 고려합니다.
std::span과 std::mdspan의 역할 분담
std::span<T>: 1차원 연속 구간의 뷰std::mdspan<T, Extents, Layout, Accessor>: 다차원 인덱싱/레이아웃/스트라이드까지 포함한 뷰
실무에서는 “버퍼는 span으로 받고, 내부에서 의미 부여를 위해 mdspan으로 재해석”하는 방식이 깔끔합니다.
#include <mdspan>
#include <span>
#include <cstddef>
#include <cassert>
using fspan = std::span<float>;
using mat = std::mdspan<float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right>;
mat make_mat(fspan buf, std::size_t r, std::size_t c) {
assert(buf.size() == r * c);
return mat(buf.data(), r, c);
}
이렇게 하면 호출자는 단순히 “연속 메모리”만 제공하면 되고, 피호출자는 “2D 의미”를 타입으로 갖게 됩니다.
스트라이드/피치가 있는 버퍼: layout_stride
이미지 처리나 GPU 업로드 버퍼처럼 한 행이 cols보다 큰 pitch(패딩)를 갖는 경우가 많습니다. 이때는 std::layout_stride가 유용합니다.
#include <mdspan>
#include <cstddef>
using image_view = std::mdspan<unsigned char,
std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>,
std::layout_stride
>;
image_view make_pitched_u8(unsigned char* base, std::size_t h, std::size_t w, std::size_t pitch_bytes) {
// 2D에서 stride는 각 차원의 “다음 원소로 가기 위한” 오프셋(원소 단위)입니다.
// unsigned char는 1바이트라 pitch_bytes를 그대로 원소 stride로 사용 가능.
std::array<std::size_t, 2> strides = { pitch_bytes, 1 };
return image_view(base, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>(h, w),
std::layout_stride::mapping<decltype(image_view::extents_type)>(
std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>(h, w),
strides));
}
layout_stride는 “연속”이라는 가정을 버리고, 각 차원의 stride를 명시합니다. 다만 이 경우 컴파일러가 연속성을 가정한 최적화를 하기 어렵기 때문에, 가능하다면 연속 배치(layout_right/layout_left)를 유지하는 편이 성능상 유리합니다.
수명 안전: mdspan에서 가장 자주 터지는 실수들
mdspan은 포인터 기반 뷰이므로, 다음 패턴은 쉽게 댕글링을 만듭니다.
실수 1) 임시 컨테이너에서 만든 mdspan을 반환
#include <mdspan>
#include <vector>
#include <cstddef>
using mat = std::mdspan<float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right>;
mat bad() {
std::vector<float> tmp(16);
// tmp는 함수 종료 시 파괴되므로, 아래 mdspan은 곧 댕글링
return mat(tmp.data(), 4, 4);
}
이 코드는 “컴파일은 되는데 런타임에서 언젠가 터지는” 전형적인 수명 버그입니다.
해결: 소유 타입과 뷰 타입을 분리하고, 반환은 소유 타입으로
#include <mdspan>
#include <vector>
#include <cstddef>
struct Matrix {
std::size_t r, c;
std::vector<float> buf;
using view_type = std::mdspan<float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right>;
using const_view_type = std::mdspan<const float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right>;
view_type view() { return view_type(buf.data(), r, c); }
const_view_type view() const { return const_view_type(buf.data(), r, c); }
};
Matrix make_matrix(std::size_t r, std::size_t c) {
Matrix m{r, c, std::vector<float>(r * c)};
return m; // 소유를 반환
}
이 패턴의 장점은 단순합니다.
- 외부에
mdspan을 “오래 들고 있을” 이유가 줄어듦 - 수명은
Matrix가 책임지고,mdspan은 함수 경계에서만 잠깐 사용
실수 2) 비동기 작업에 mdspan을 캡처
스레드 풀에 작업을 던지거나 코루틴으로 넘길 때, 호출자 스택/임시 버퍼를 가리키는 mdspan을 캡처하면 재현 어려운 크래시가 됩니다.
해결: 비동기 경계에서는 소유로 승격
#include <mdspan>
#include <vector>
#include <cstddef>
using cmat = std::mdspan<const float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right>;
struct OwnedMat {
std::size_t r, c;
std::vector<float> buf;
cmat view() const { return cmat(buf.data(), r, c); }
};
OwnedMat clone(cmat m) {
OwnedMat out{m.extent(0), m.extent(1), {}};
out.buf.resize(out.r * out.c);
for (std::size_t i = 0; i < out.r; ++i)
for (std::size_t j = 0; j < out.c; ++j)
out.buf[i * out.c + j] = m(i, j);
return out;
}
// 비동기 큐에 넣기 전 clone해서 수명을 고정하는 식
0복사가 목표라도, 비동기 경계에서는 “수명 고정 비용”을 치르는 편이 전체 시스템 안정성이 훨씬 좋습니다. 성능 이슈가 생기면 그때 버퍼 풀/참조 카운팅/아레나 등으로 최적화하면 됩니다.
실수 3) 전역/정적 캐시에 mdspan을 저장
mdspan은 “현재 메모리”를 가리키는 핸들이라서, 캐시에 저장하면 원본 버퍼가 리사이즈되거나 이동되면서 바로 무효가 됩니다. 특히 std::vector는 재할당 시 data()가 바뀝니다.
해결: 캐시에는 소유 혹은 안정 주소를 저장
- 소유:
std::vector/std::unique_ptr/커스텀 버퍼 - 안정 주소: 고정된 메모리 풀,
std::pmr리소스,new[]로 고정 할당 후 이동 금지
API 설계 팁: “뷰는 인자, 소유는 멤버”
실무에서 가장 깔끔한 규칙은 아래입니다.
- 함수 인자:
mdspan(또는span)을 받아 0복사 처리 - 함수 반환: 가능한 한 소유 타입(혹은 값 타입)으로 반환
- 멤버 저장:
mdspan을 멤버로 저장하지 말고, 필요할 때 생성
예를 들어 선형대수 커널은 이렇게 둡니다.
#include <mdspan>
#include <cstddef>
template <class T>
using mat_view = std::mdspan<T, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>, std::layout_right>;
template <class T>
void matmul_naive(mat_view<const T> A, mat_view<const T> B, mat_view<T> C) {
const std::size_t m = A.extent(0);
const std::size_t k = A.extent(1);
const std::size_t n = B.extent(1);
// 계약: A는 m x k, B는 k x n, C는 m x n
for (std::size_t i = 0; i < m; ++i) {
for (std::size_t j = 0; j < n; ++j) {
T sum{};
for (std::size_t t = 0; t < k; ++t) sum += A(i, t) * B(t, j);
C(i, j) = sum;
}
}
}
이 커널은 메모리 소유와 무관하게 동작합니다. 호출자는 vector든, 고정 배열이든, 매핑된 파일이든, GPU 핀 버퍼든(호스트 접근 가능하다면) 같은 인터페이스로 연결할 수 있습니다.
성능 관점: mdspan을 “제로 오버헤드”로 쓰려면
mdspan 자체는 얇은 래퍼지만, 아래 선택에 따라 코드 생성이 달라집니다.
- 가능하면
layout_right/layout_left를 사용
- 연속성 가정이 성능에 유리합니다.
- extents를 부분 정적으로 만들면 최적화 여지가 커짐
- 예: 채널 수가 고정(3채널 RGB)이라면 해당 차원을 정적으로.
- 경계 검사 없음이 기본
operator()는 보통 범위 체크를 하지 않습니다. 디버그에서만assert로 계약을 검증하는 패턴이 일반적입니다.
- 컴파일러/표준 라이브러리 구현 상태 확인
std::mdspan은 비교적 최신 구성요소라, 컴파일러 버전과libstdc++/libc++버전에 따라 준비 정도가 다를 수 있습니다.
CI에서 컴파일러/표준 라이브러리 매트릭스를 운영한다면, 캐시 전략이 빌드 시간을 좌우합니다. 이 주제는 GitHub Actions 캐시 무효화로 빌드 느림 해결이 실전 팁 위주로 정리돼 있습니다.
mdspan 도입 체크리스트
도입 전
- 데이터가 정말 “다차원 의미”를 갖는가? 1D면
std::span으로 충분한가? - 레이아웃이 고정인가?
layout_right로 통일 가능한가? - 비동기/캐시 경계가 있는가? 있다면 소유 승격 지점을 명확히 할 것
도입 후
- 뷰를 반환하지 않는 규칙을 린트/리뷰 체크 항목으로
vector리사이즈/재할당이 가능한 경로에서mdspan장기 보관 금지- 디버그 빌드에서 extents 계약을
assert로 검증
마무리
std::mdspan은 “다차원 배열을 포인터로 넘기며 생기는 관례적 혼란”을 표준 라이브러리 수준에서 정리해 줍니다. 0복사 뷰로 성능을 지키면서도, 레이아웃·스트라이드 같은 중요한 정보를 타입/값으로 명시해 유지보수성을 크게 올릴 수 있습니다.
다만 수명은 자동으로 안전해지지 않습니다. 그래서 실무에서는 mdspan을 함수 인자(일시적 뷰) 로만 쓰고, 반환·저장·비동기 경계에서는 소유 타입으로 승격하는 규칙이 가장 효과적입니다. 이 규칙만 지켜도 mdspan은 “빠른데 위험한 도구”가 아니라, “빠르고 예측 가능한 인터페이스”가 됩니다.