Published on

C++23 std - -mdspan 오용으로 터지는 UB 디버깅

Authors

서론

std::mdspan은 C++23에서 표준화된 다차원 뷰(view)입니다. 핵심은 “소유하지 않는다”는 점입니다. 그래서 std::vectornew[]처럼 메모리를 들고 있는 컨테이너가 아니라, 이미 존재하는 연속/비연속 메모리를 다차원으로 해석하는 얇은 래퍼에 가깝습니다.

이 특성 때문에 mdspan은 성능과 표현력은 뛰어나지만, 오용하면 UB(Undefined Behavior)가 매우 쉽게 터집니다. 특히 다음 상황에서 디버깅이 어렵습니다.

  • 코드가 컴파일되고, 릴리즈에서는 가끔만 크래시가 나거나 결과가 흔들림
  • 디버그에서는 멀쩡한데 최적화 옵션에서만 깨짐
  • 포인터/스트라이드/레이아웃이 맞는 것처럼 보이는데 특정 인덱스에서만 오염

이 글에서는 std::mdspan에서 흔한 UB 패턴을 재현 코드로 만들고, Sanitizer와 디버깅 포인트를 통해 원인을 빠르게 좁히는 방법을 정리합니다. (인프라/CI에서 재현을 고정하는 습관도 중요하니, 빌드 파이프라인 관점은 GitHub Actions로 Docker Buildx·SBOM·이미지 서명도 함께 참고하면 좋습니다.)

std::mdspan 기본: 왜 UB가 잘 터지나

mdspan의 구성 요소는 크게 3가지입니다.

  • 데이터 핸들: 보통 포인터(T*) 또는 커스텀 핸들
  • 매핑(mapping): 레이아웃과 스트라이드 계산(layout_right, layout_left, layout_stride)
  • 익스텐트(extents): 각 차원의 크기(정적/동적)

mdspan 자체는 경계 검사(bound check)를 하지 않습니다(구현에 따라 디버그 모드에서 assert를 넣을 수는 있지만 표준 요구는 아님). 즉, 인덱싱이 잘못되거나, 수명이 끝난 메모리를 가리키거나, 잘못된 스트라이드를 주면 그대로 UB입니다.

UB 패턴 1: 임시 컨테이너에서 mdspan을 만들어 반환

가장 흔한 실수는 “임시 버퍼를 만들고 mdspan을 반환”하는 형태입니다. mdspan은 소유하지 않으므로, 반환과 동시에 버퍼가 파괴되면 mdspan은 댕글링 포인터를 갖게 됩니다.

#include <mdspan>
#include <vector>

using matrix_view = std::mdspan<float, std::dextents<size_t, 2>>;

matrix_view make_bad_view(size_t r, size_t c) {
    std::vector<float> tmp(r * c, 1.0f);
    // tmp는 곧 파괴되는데, mdspan은 tmp.data()만 들고 있음
    return matrix_view(tmp.data(), r, c);
}

int main() {
    auto m = make_bad_view(4, 4);
    // 여기서부터는 이미 UB: m이 가리키는 메모리는 해제됨
    m(0, 0) = 3.14f;
}

디버깅 포인트

  • ASan(AddressSanitizer)에서 heap-use-after-free로 즉시 잡히는 전형적인 케이스입니다.
  • 크래시가 안 나더라도 값이 랜덤하게 바뀌거나, 다른 힙 객체를 덮어써서 후속 위치에서 터집니다.

해결 패턴

  1. 소유 객체를 함께 반환해서 수명을 묶습니다.
#include <mdspan>
#include <vector>
#include <utility>

struct owned_matrix {
    std::vector<float> storage;
    std::mdspan<float, std::dextents<size_t, 2>> view;

    owned_matrix(size_t r, size_t c)
        : storage(r * c), view(storage.data(), r, c) {}
};

owned_matrix make_ok(size_t r, size_t c) {
    return owned_matrix(r, c);
}
  1. 또는 호출자가 소유하고, 함수는 mdspan만 “빌려서” 씁니다.
void fill(std::mdspan<float, std::dextents<size_t, 2>> m) {
    for (size_t i = 0; i < m.extent(0); ++i)
        for (size_t j = 0; j < m.extent(1); ++j)
            m(i, j) = 1.0f;
}

UB 패턴 2: 레이아웃 착각(layout_right vs layout_left)

mdspan은 레이아웃에 따라 인덱스에서 오프셋을 계산합니다.

  • layout_right: 마지막 차원이 연속(일반적인 C/C++의 row-major)
  • layout_left: 첫 차원이 연속(Fortran 스타일 column-major)

데이터는 row-major인데 layout_left로 해석하면, 같은 (i, j)가 다른 주소를 가리키게 됩니다. 이 자체는 “다른 값”을 읽는 버그일 수 있지만, 차원/스트라이드가 결합되면 경계를 넘어 UB로 이어질 수 있습니다.

#include <mdspan>
#include <vector>
#include <iostream>

int main() {
    size_t r = 2, c = 3;
    std::vector<int> a = {0,1,2,3,4,5}; // row-major 2x3

    using ext2 = std::dextents<size_t, 2>;
    std::mdspan<int, ext2, std::layout_right> row(a.data(), r, c);
    std::mdspan<int, ext2, std::layout_left>  col(a.data(), r, c);

    std::cout << row(1, 2) << "\n"; // 기대: 5
    std::cout << col(1, 2) << "\n"; // 다른 위치를 읽음
}

디버깅 포인트

  • 값이 “규칙적으로 틀리는” 경우는 레이아웃 착각을 의심합니다.
  • 특히 외부 라이브러리(예: BLAS 계열)가 column-major를 기대하는데 row-major 데이터를 넘기는 경우가 많습니다.

해결 패턴

  • 레이아웃을 명시적으로 통일하고, 외부 라이브러리 경계에서만 변환(또는 전치)합니다.
  • 전치 뷰가 필요하면, 무턱대고 포인터를 바꾸지 말고 layout_stride로 스트라이드를 정확히 모델링합니다.

UB 패턴 3: submdspan으로 만든 뷰의 범위 착각

부분 뷰를 만들 때는 원본의 익스텐트와 슬라이스 범위가 맞아야 합니다. 슬라이스 계산을 잘못하면 경계 밖을 가리키는 뷰가 만들어지고, 이후 접근은 UB입니다.

#include <mdspan>
#include <vector>

int main() {
    std::vector<float> a(4 * 4);
    std::mdspan<float, std::dextents<size_t, 2>> m(a.data(), 4, 4);

    // 예: 4x4에서 행 [2, 6) 같은 범위를 만들면 논리적으로 불가능
    // 구현에 따라 생성은 되더라도 이후 접근은 경계 밖
    auto bad = std::submdspan(m, std::pair{2u, 6u}, std::full_extent);

    // UB 가능
    bad(3, 0) = 1.0f;
}

디버깅 포인트

  • submdspan 결과의 extent(n)를 즉시 로그/검증합니다.
  • 슬라이스 인덱스가 size_t라면 언더플로(특히 end = start - k)도 자주 발생합니다.

해결 패턴

  • 슬라이스 범위를 만들기 전에 입력을 검증하고, 디버그 빌드에서는 assert를 강제합니다.
#include <cassert>

auto safe_slice = [&](auto m, size_t r0, size_t r1) {
    assert(r0 <= r1);
    assert(r1 <= m.extent(0));
    return std::submdspan(m, std::pair{r0, r1}, std::full_extent);
};

UB 패턴 4: layout_stride에서 스트라이드 단위 혼동(바이트 vs 요소)

layout_stride는 스트라이드를 “요소 개수 단위”로 받습니다. 바이트 단위로 넣으면 오프셋이 과도하게 커져 경계를 넘어갑니다.

#include <mdspan>
#include <array>
#include <vector>

int main() {
    size_t r = 3, c = 3;
    std::vector<double> a(r * c);

    using ext2 = std::dextents<size_t, 2>;

    // 잘못된 예: 바이트 단위로 stride를 계산
    // double은 8바이트인데, stride를 24(=3*8)로 넣으면 요소 24개씩 점프
    std::array<size_t, 2> bad_stride = {c * sizeof(double), sizeof(double)};

    std::mdspan<double, ext2, std::layout_stride> bad(
        a.data(), std::layout_stride::mapping<ext2>(ext2(r, c), bad_stride)
    );

    // UB 가능: 내부 오프셋 계산이 엉망
    bad(1, 1) = 2.0;
}

올바른 스트라이드

std::array<size_t, 2> good_stride = {c, 1};

디버깅 포인트

  • “특정 인덱스에서만 갑자기 큰 값이 튄다/크래시 난다”는 스트라이드 의심 신호입니다.
  • mapping.stride(n)를 출력해 실제 stride가 기대와 일치하는지 확인합니다.

UB 패턴 5: 정렬(Alignment) 위반과 타입 재해석

외부 버퍼(예: 네트워크 패킷, 파일 매핑, GPU pinned memory)를 std::byte*로 받고 이를 T*로 캐스팅해 mdspan<T>를 만드는 경우가 있습니다. 이때 정렬이 맞지 않으면 UB입니다.

#include <mdspan>
#include <cstddef>
#include <cstdint>

void bad_alias(std::byte* raw, size_t n) {
    // raw가 float 정렬을 만족한다는 보장이 없으면 UB
    auto p = reinterpret_cast<float*>(raw);
    std::mdspan<float, std::dextents<size_t, 1>> v(p, n);
    v(0) = 1.0f;
}

해결 패턴

  • 정렬이 보장된 할당자를 사용하거나
  • std::assume_aligned 같은 힌트를 쓰기 전에 실제 정렬을 검증하거나
  • 안전한 복사(언팩) 경로를 둡니다.

정렬/aliasing 문제는 UBSan에서 alignment 관련 런타임 에러로 잡히는 경우가 많습니다.

Sanitizer로 UB를 “재현 가능한 실패”로 바꾸기

mdspan UB는 재현성이 낮을 때가 많습니다. 이럴수록 Sanitizer를 켜서 “즉시 실패”로 바꾸는 게 가장 빠릅니다.

Clang/GCC 추천 옵션

아래 옵션은 예시이며, 프로젝트 상황에 맞게 조정하세요.

# Clang 또는 GCC
# 주소/UB/스택 추적 강화
CXXFLAGS="-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined"
LDFLAGS="-fsanitize=address,undefined"

# 실행 시 추가(ASan)
ASAN_OPTIONS="detect_stack_use_after_return=1:strict_string_checks=1:check_initialization_order=1"
  • -O1은 최적화로 인해 스택/레지스터가 사라지는 문제를 줄이면서도 실제 코드 경로를 어느 정도 유지합니다.
  • -fno-omit-frame-pointer는 스택 트레이스를 훨씬 읽기 좋게 만듭니다.

CI에서도 Sanitizer 잡을 돌리면 “로컬에서는 안 터지는 UB”를 잡을 확률이 크게 올라갑니다. (CI 파이프라인 구성은 GitHub Actions로 Docker Buildx·SBOM·이미지 서명처럼 재현성과 아티팩트 관리를 신경 쓰는 방식이 도움이 됩니다.)

디버깅 체크리스트: mdspan에서 의심할 것 8가지

문제가 생겼을 때 아래를 순서대로 확인하면 원인 좁히기가 빨라집니다.

  1. 수명: mdspan.data_handle()이 가리키는 메모리가 아직 살아 있는가
  2. extent: extent(0..N-1)가 기대한 크기인가
  3. 레이아웃: row-major인지 column-major인지, 외부 API 기대와 일치하는가
  4. 스트라이드 단위: 요소 단위인지(바이트 단위로 넣지 않았는지)
  5. 오프셋 계산: 시작 포인터를 이동시켰다면(예: ROI), 그 이동이 요소 단위로 맞는가
  6. 정렬: T*로 캐스팅한 메모리가 alignof(T)를 만족하는가
  7. 동시성: 다른 스레드가 같은 버퍼를 쓰고 있지 않은가(TSan 필요)
  8. 경계 조건: 루프 상한이 extent와 정확히 일치하는가(특히 <= 실수)

안전한 래핑: 디버그에서만 경계 검사 추가하기

표준 mdspan은 기본적으로 경계 검사가 없습니다. 팀 코드베이스에서 자주 실수한다면, 디버그 빌드에서만 체크하는 얇은 래퍼를 두는 것도 효과적입니다.

#include <mdspan>
#include <cassert>

template <class T, class Extents, class Layout = std::layout_right>
struct checked_mdspan {
    std::mdspan<T, Extents, Layout> m;

    template <class... Index>
    T& operator()(Index... idx) const {
        static_assert(sizeof...(Index) == Extents::rank());
        size_t i[] = {static_cast<size_t>(idx)...};
        for (size_t d = 0; d < Extents::rank(); ++d) {
            assert(i[d] < m.extent(d));
        }
        return m(idx...);
    }
};

릴리즈에서 오버헤드를 없애려면 NDEBUG에 따라 assert를 제거하거나, 별도 컴파일 플래그로 제어하면 됩니다.

결론: mdspan은 “소유권 없음”을 항상 의식해야 한다

std::mdspan 디버깅의 본질은 간단합니다.

  • mdspan은 포인터와 매핑만 가진다
  • 따라서 수명, 레이아웃, 스트라이드, 정렬 중 하나만 틀려도 UB가 된다

가장 좋은 예방책은 (1) 소유 객체와 뷰를 함께 묶는 설계, (2) Sanitizer를 상시로 돌리는 개발/CI 습관, (3) 디버그에서의 경계 검사 보강입니다.

UB는 “원인이 보이는 곳”이 아니라 “결과가 터진 곳”에서 발견되는 경우가 많습니다. mdspan을 쓰는 코드라면, 의심 지점을 수명과 매핑부터 역추적하는 방식으로 접근하면 디버깅 시간이 크게 줄어듭니다.

추가로, 타입 시스템이 잡아주지 않는 실수(예: 경계/인덱스 관련)는 다른 언어에서도 비슷한 양상으로 나타납니다. 런타임 실패를 빠르게 관찰 가능하게 만드는 접근(옵션 강화, 가드 추가, 재현성 확보)은 TS 5.5 noUncheckedIndexedAccess 에러 해결 가이드처럼 “인덱싱 안전성”을 높이는 전략과도 통하는 면이 있습니다.