- Published on
C++23 std - -mdspan 메모리 레이아웃 오류 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
C++23의 std::mdspan은 “포인터 + 레이아웃(인덱스 매핑)”을 결합해 다차원 뷰를 안전하고 빠르게 다루는 도구입니다. 문제는 mdspan 자체가 메모리를 소유하지 않는 뷰라는 점 때문에, 레이아웃(순서), stride(보폭), extent(크기) 중 하나만 잘못 맞춰도 결과가 조용히 틀어지기 쉽다는 것입니다. 디버깅이 어려운 이유는 대개 크래시가 아니라 “값이 이상함”으로 나타나기 때문입니다.
이 글에서는 std::mdspan에서 흔히 발생하는 메모리 레이아웃 오류를 재현 가능한 형태로 분류하고, 컴파일타임 검증 + 런타임 검증 + 관찰 가능한 출력으로 빠르게 잡는 패턴을 정리합니다. (접근 방식 자체는 다른 디버깅 글에서의 원칙과 유사합니다. 예를 들어 PyTorch→ONNX→INT8 양자화 정확도 하락 잡기처럼 “원인 후보를 계층화하고 재현을 고정”하는 게 핵심입니다.)
주의: 본문은 Next.js MDX 렌더링을 고려해 일반 텍스트에 부등호 문자를 쓰지 않습니다. 모든 부등호는 코드 블록 또는 인라인 코드로만 표기합니다.
mdspan에서 레이아웃 버그가 생기는 전형적 패턴
mdspan의 핵심은 “(i, j, ...)를 1차원 인덱스로 매핑”하는 layout 입니다. 여기서 실수는 크게 4가지로 나뉩니다.
1) Row-major vs Column-major 혼동
- C/C++ 배열 관례는 보통 row-major(
layout_right) - Fortran/BLAS/LAPACK 쪽 관례는 column-major(
layout_left)
외부 라이브러리로부터 받은 버퍼를 layout_right로 해석하면, 크래시 없이 값이 뒤집혀 보입니다.
2) Stride(leading dimension) 불일치
2차원 행렬에서 실제 메모리는 N 열이 아니라 패딩 포함 ld(leading dimension)만큼 건너뛰는 경우가 많습니다.
- 예: GPU/벡터화 정렬 때문에 한 행이 64바이트 정렬
- 예: 서브매트릭스가 원본의 일부라서 행 간격이 원본 기준
이때 단순 layout_right로 만들면 stride를 무시하게 됩니다.
3) Subview 오프셋 계산 실수
서브뷰는 “포인터를 이동 + extents 조정 + stride 유지”가 같이 맞아야 합니다. 포인터만 이동하거나 extents만 줄이면 즉시 논리 오류가 납니다.
4) extent(차원 크기) 자체가 틀림
가장 단순하지만 흔합니다. 특히 m과 n을 바꿔 넣거나, size_t와 int 변환 과정에서 음수가 큰 값으로 변하는 케이스가 있습니다.
환경 준비: 컴파일러와 헤더
std::mdspan은 C++23에 포함되었지만, 표준 라이브러리 구현/플래그는 컴파일러마다 차이가 있습니다. 예제는 개념 중심이며, 실제로는 다음 중 하나를 사용하세요.
- 최신 clang/libc++ 또는 gcc/libstdc++에서
-std=c++23 - 구현이 불완전하면
mdspan백포트(예:std::experimental::mdspan)를 쓰되, 본문 코드는 표준std::mdspan기준으로 설명합니다.
버그 재현 1: row-major로 저장된 데이터를 column-major로 읽기
먼저 “틀린 레이아웃을 씌우면 어떤 증상이 나오는지”를 고정된 데이터로 재현합니다.
#include <mdspan>
#include <vector>
#include <iostream>
#include <cassert>
static void print2d(auto a) {
for (std::size_t i = 0; i < a.extent(0); ++i) {
for (std::size_t j = 0; j < a.extent(1); ++j) {
std::cout << a(i, j) << ' ';
}
std::cout << '\n';
}
}
int main() {
constexpr std::size_t M = 2;
constexpr std::size_t N = 3;
// row-major로 채운 버퍼: [ [1 2 3], [4 5 6] ]
std::vector<int> buf = {1,2,3,4,5,6};
using right_t = std::mdspan<int, std::extents<std::size_t, M, N>, std::layout_right>;
using left_t = std::mdspan<int, std::extents<std::size_t, M, N>, std::layout_left>;
right_t a_right(buf.data());
left_t a_left(buf.data());
std::cout << "layout_right\n";
print2d(a_right);
std::cout << "layout_left (wrong for this buffer)\n";
print2d(a_left);
}
이 코드는 크래시 없이 “값이 재배치된 것처럼” 출력됩니다. 이런 유형은 테스트가 없으면 놓치기 쉽습니다.
빠른 진단 체크리스트
- 외부에서 받은 버퍼인가
- 해당 라이브러리 문서가 column-major를 기본으로 하는가
leading dimension같은 용어가 등장하는가
버그 재현 2: stride가 있는 행렬을 layout_right로 해석
실전에서 더 흔한 건 stride 문제입니다. 예를 들어 M x N 행렬인데 실제 메모리는 행마다 ld 만큼 떨어져 있다고 가정합니다.
#include <mdspan>
#include <vector>
#include <iostream>
#include <cassert>
int main() {
constexpr std::size_t M = 3;
constexpr std::size_t N = 4;
constexpr std::size_t ld = 8; // 한 행의 실제 보폭(패딩 포함)
std::vector<int> buf(M * ld, -1);
// 논리 행렬 영역(M x N)에만 값 채우기
int v = 1;
for (std::size_t i = 0; i < M; ++i) {
for (std::size_t j = 0; j < N; ++j) {
buf[i * ld + j] = v++;
}
}
// 잘못된 해석: stride를 무시하고 M*N이 연속이라고 가정
using wrong_t = std::mdspan<int, std::extents<std::size_t, M, N>, std::layout_right>;
wrong_t wrong(buf.data());
// 올바른 해석: layout_stride로 명시적인 stride 제공
using ext_t = std::extents<std::size_t, M, N>;
using map_t = std::layout_stride::mapping<ext_t>;
map_t map(ext_t{}, std::array<std::size_t, 2>{ld, 1});
std::mdspan<int, ext_t, std::layout_stride> ok(buf.data(), map);
std::cout << "wrong (ignores ld)\n";
for (std::size_t i = 0; i < M; ++i) {
for (std::size_t j = 0; j < N; ++j) {
std::cout << wrong(i, j) << ' ';
}
std::cout << '\n';
}
std::cout << "ok (layout_stride)\n";
for (std::size_t i = 0; i < M; ++i) {
for (std::size_t j = 0; j < N; ++j) {
std::cout << ok(i, j) << ' ';
}
std::cout << '\n';
}
}
wrong는 1행 끝의 패딩 영역을 다음 행의 시작으로 착각합니다. 이때 패딩이 -1 같은 값이면 출력에서 바로 티가 나지만, 실제 서비스에서는 패딩이 이전 데이터/미초기화 값이라 “가끔만 틀리는” 형태로 나타납니다.
레이아웃 오류를 “빨리” 잡는 관측 포인트
1) 인덱스 매핑을 숫자로 출력하기
mdspan은 내부적으로 mapping(i, j) 같은 방식으로 1차원 오프셋을 계산합니다. 구현 세부는 라이브러리마다 다르지만, 레이아웃 검증의 핵심은 결국 “내가 기대하는 오프셋이 맞는지”입니다.
아래처럼 layout_stride를 쓴다면 stride를 기반으로 기대 오프셋을 직접 계산해 비교할 수 있습니다.
#include <mdspan>
#include <array>
#include <cassert>
static std::size_t expected_offset(std::size_t i, std::size_t j, std::size_t ld) {
return i * ld + j;
}
int main() {
constexpr std::size_t M = 2;
constexpr std::size_t N = 3;
constexpr std::size_t ld = 5;
using ext_t = std::extents<std::size_t, M, N>;
using map_t = std::layout_stride::mapping<ext_t>;
map_t map(ext_t{}, std::array<std::size_t, 2>{ld, 1});
for (std::size_t i = 0; i < M; ++i) {
for (std::size_t j = 0; j < N; ++j) {
auto off = map(i, j);
assert(off == expected_offset(i, j, ld));
}
}
}
이 검증은 “값이 맞는지”가 아니라 “주소 계산이 맞는지”를 직접 확인하므로, 데이터가 우연히 맞아 보이는 상황도 잡아냅니다.
2) 경계값에 센티넬(sentinel) 심기
stride/오프셋 오류는 보통 경계에서 터집니다.
- 각 행의 패딩 첫 칸에 고정 패턴을 넣고 그 값이 읽히는지 확인
- 버퍼 전체를
0xCD같은 패턴으로 채운 뒤 논리 영역만 덮어쓰기
C++에서 정수 벡터라면 단순히 -777777 같은 값을 패딩에 넣는 것만으로도 효과가 큽니다.
3) 단위 테스트에서 “레이아웃”을 고정된 계약으로 테스트
mdspan을 쓰는 함수는 “이 함수가 기대하는 레이아웃/stride 계약”이 있습니다. 그 계약을 테스트로 못 박아두면 회귀가 줄어듭니다.
예를 들어 아래 함수는 layout_right 연속 행렬만 받는다고 가정합니다.
#include <mdspan>
#include <cassert>
template<class T, class Ext>
void add_one_contiguous(std::mdspan<T, Ext, std::layout_right> a) {
// 연속이라는 가정이 깨지면 성능 최적화도, 결과도 깨질 수 있음
for (std::size_t i = 0; i < a.extent(0); ++i) {
for (std::size_t j = 0; j < a.extent(1); ++j) {
a(i, j) += T{1};
}
}
}
테스트에서는 layout_stride로 만든 뷰를 실수로 넘기지 못하게 타입으로 막는 것이 1차 방어선입니다.
실전 방어선 1: 타입으로 레이아웃을 제한하기
가장 강력한 방법은 “받을 수 있는 레이아웃을 타입으로 제한”하는 것입니다.
- 연속 row-major만 받는 API는
std::layout_right로 고정 - stride 가능성을 열어둬야 하면
std::layout_stride를 받되, 내부에서 반드시 stride를 사용
특히 성능 최적화를 위해 memcpy나 SIMD 최적화를 하려면 “진짜 연속인지”가 중요합니다. 이때 레이아웃을 느슨하게 받으면 최적화가 곧 버그가 됩니다.
실전 방어선 2: 런타임 계약 검사(디버그 빌드)
템플릿으로 다 막기 어려운 경우(예: 레이아웃이 템플릿 파라미터로 들어오는 라이브러리 코드)에는 디버그에서 계약 검사를 넣는 게 좋습니다.
layout_right의 연속성을 일반적으로 판정하려면 다음을 확인합니다.
- 마지막 차원의 stride가 1
- 그 앞 차원의 stride가
extent(last) - 그 앞은
extent(prev) * stride(prev)
표준이 “모든 레이아웃에 대해 stride 질의 API를 제공”하는 형태는 제한적이라, 실무에서는 다음 중 하나를 씁니다.
- 애초에
layout_stride로 통일하고 stride를 들고 다닌다 - 특정 레이아웃만 받도록 타입을 고정한다
즉, 검사 가능하게 설계하는 것이 디버깅 비용을 줄입니다. 이는 스키마 검증으로 입력을 고정해 장애를 줄이는 방식과 유사합니다. API 입력 계약을 강제하는 관점은 OpenAI Responses API 422 스키마 검증 에러 해결 가이드에서 다루는 “스키마로 실패를 앞당기기”와 결이 같습니다.
버그 재현 3: 서브뷰 오프셋을 잘못 준 경우
서브매트릭스를 만들 때 흔한 실수는 “포인터 이동량을 N으로 계산”하는 것입니다. 원본이 stride를 가지면 N이 아니라 ld를 써야 합니다.
#include <mdspan>
#include <vector>
#include <array>
#include <iostream>
int main() {
constexpr std::size_t M = 4;
constexpr std::size_t N = 4;
constexpr std::size_t ld = 6;
std::vector<int> buf(M * ld, 0);
int v = 1;
for (std::size_t i = 0; i < M; ++i) {
for (std::size_t j = 0; j < N; ++j) {
buf[i * ld + j] = v++;
}
}
using ext_t = std::extents<std::size_t, M, N>;
using map_t = std::layout_stride::mapping<ext_t>;
map_t map(ext_t{}, std::array<std::size_t, 2>{ld, 1});
std::mdspan<int, ext_t, std::layout_stride> A(buf.data(), map);
// (1,1)에서 시작하는 2x2 서브뷰를 만들고 싶다
constexpr std::size_t subM = 2;
constexpr std::size_t subN = 2;
using subext_t = std::extents<std::size_t, subM, subN>;
// 잘못된 포인터 이동: i*N + j 로 계산
int* wrong_ptr = buf.data() + (1 * N + 1);
// 올바른 포인터 이동: i*ld + j 로 계산
int* ok_ptr = buf.data() + (1 * ld + 1);
// 서브뷰도 stride는 원본과 동일하게 유지
using submap_t = std::layout_stride::mapping<subext_t>;
submap_t submap(subext_t{}, std::array<std::size_t, 2>{ld, 1});
std::mdspan<int, subext_t, std::layout_stride> S_wrong(wrong_ptr, submap);
std::mdspan<int, subext_t, std::layout_stride> S_ok(ok_ptr, submap);
std::cout << "S_wrong\n";
for (std::size_t i = 0; i < subM; ++i) {
for (std::size_t j = 0; j < subN; ++j) {
std::cout << S_wrong(i, j) << ' ';
}
std::cout << '\n';
}
std::cout << "S_ok\n";
for (std::size_t i = 0; i < subM; ++i) {
for (std::size_t j = 0; j < subN; ++j) {
std::cout << S_ok(i, j) << ' ';
}
std::cout << '\n';
}
}
이 문제는 특히 “원본은 stride가 있는데, 서브뷰는 연속이라고 착각”할 때 터집니다.
디버깅 루틴: 의심 순서대로 좁히기
레이아웃 오류는 원인이 겹치기 쉬워서, 아래 순서로 확인하는 게 효율적입니다.
1) extents가 맞는지부터 확인
extent(0),extent(1)출력- 입력
m,n이 바뀌지 않았는지 - 0 크기 차원이 섞이지 않았는지
2) 레이아웃 방향 확인
- 데이터 생성 측이 row-major인지 column-major인지 문서로 확인
- 의심되면 동일 버퍼를
layout_right와layout_left로 각각 감싸서 비교 출력
3) stride 존재 여부 확인
- “leading dimension”, “pitch”, “stride”가 있는 API인지
- 서브매트릭스/ROI를 다루는지
- SIMD/GPU/정렬 최적화로 패딩이 들어갈 수 있는지
4) 서브뷰 포인터 오프셋 검증
- 포인터 이동은 “논리 크기”가 아니라 “실제 stride” 기준인지
- 오프셋을 숫자로 출력해 손으로 계산한 값과 비교
이런 디버깅은 결국 “관측 가능한 신호를 만들고, 실패를 앞당기는” 작업입니다. 운영 장애에서 로그로 원인을 빠르게 좁히는 방식과 동일한 사고 흐름이며, 그런 관점은 K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기 같은 글의 접근과도 통합니다.
권장 설계 패턴: layout_stride를 경계로 삼기
실무에서는 다음 패턴이 안정적입니다.
- 외부에서 들어오는 2D/3D 버퍼는 기본적으로 stride가 있다고 가정
- API 경계에서는
std::layout_stride로 받는다 - 내부에서 “정말 연속”이 필요하면, 별도 경로로
layout_right전용 오버로드를 제공하거나, 연속 버퍼로 복사해 정규화한다
즉, mdspan의 장점(제로-코스트 뷰)을 살리되, 계약을 명확히 해서 “조용히 틀리는” 상황을 줄이는 게 핵심입니다.
마무리: 레이아웃 버그는 주소 계산 버그다
std::mdspan에서 발생하는 대부분의 메모리 레이아웃 오류는 “값 계산 버그”가 아니라 “주소 계산 버그”입니다. 그래서 다음 3가지만 습관화하면 디버깅 시간이 크게 줄어듭니다.
- 레이아웃 방향(
layout_rightvslayout_left)을 타입/문서로 고정 - stride 가능성이 있으면
layout_stride로 명시 - 서브뷰는 포인터 오프셋과 stride를 함께 검증
원한다면 다음 후속 주제로도 확장할 수 있습니다.
std::submdspan을 이용한 안전한 서브뷰 구성 패턴- BLAS 호출 시 column-major와
mdspan매핑 레시피 - 디버그에서만 켜지는 “stride/contiguous” 계약 검사 유틸리티