Published on

C++23 std - -mdspan로 2D 배열 메모리 최적화

Authors

서버/엔진/수치해석 코드에서 2D 배열은 흔하지만, 구현 방식에 따라 성능과 메모리 효율이 크게 갈립니다. 특히 std::vector<std::vector<T>> 같은 중첩 컨테이너는 편리한 대신 비연속 메모리, 추가 인디렉션, 할당 오버헤드로 인해 캐시 효율이 떨어질 수 있습니다.

C++23의 std::mdspan은 이런 문제를 정면으로 해결합니다. 핵심은 간단합니다.

  • 실제 데이터는 std::vector<T> 같은 연속 버퍼에 둔다
  • 2D/3D 인덱싱은 std::mdspan이라는 가벼운 뷰로 제공한다
  • 레이아웃(행 우선/열 우선), 스트라이드, 서브뷰를 타입/정책으로 표현한다

이 글에서는 2D 배열을 std::mdspan으로 다루며 메모리/성능을 최적화하는 실전 패턴을 정리합니다.

vector<vector<T>> 가 느릴 수 있나

std::vector<std::vector<float>> a(h, std::vector<float>(w))는 다음 비용을 가집니다.

  • 각 행마다 별도 할당이 발생할 수 있음(행 수만큼 힙 할당)
  • a[i][j] 접근 시 a[i]를 먼저 로드한 뒤 그 안에서 j를 접근하는 2단계 인디렉션
  • 행들이 메모리 상에 흩어질 수 있어, 순차 스캔에서도 캐시 미스가 증가

반면 HPC나 이미지 처리, 격자 기반 시뮬레이션에서는 보통 연속 메모리 + 규칙적인 접근이 가장 잘 먹힙니다.

std::mdspan 한 줄 정의

std::mdspan은 “연속(혹은 규칙적 스트라이드) 메모리 위에 N차원 인덱싱을 얹는 뷰”입니다.

  • 소유권 없음(메모리를 갖지 않음)
  • 포인터 + extents(크기) + layout(인덱스 매핑) 정도만 들고 있음
  • operator()로 다차원 인덱싱 제공

즉, 데이터는 std::vector<T>가 소유하고, 함수 인자로는 mdspan을 넘겨 복사 없이 2D 인터페이스를 제공할 수 있습니다.

준비: 헤더와 컴파일러 상태

표준 헤더는 #include <mdspan> 입니다. 다만 2026년 현재 컴파일러/표준 라이브러리 조합에 따라 구현 상태가 다를 수 있습니다.

  • GCC/libstdc++: 버전에 따라 std::mdspan 지원이 제한적일 수 있음
  • Clang/libc++: 비교적 빠르게 들어오지만 배포 버전에 따라 차이

실무에서는 std::mdspan이 불완전할 경우 mdspan 레퍼런스 구현(Kokkos mdspan)을 쓰고, 나중에 표준으로 스위치하는 전략도 흔합니다.

기본 패턴: 연속 버퍼 + 2D 뷰

가장 흔한 최적화는 “2D를 1D로 저장하고, 접근만 2D로” 입니다.

#include <vector>
#include <mdspan>
#include <cstddef>

using index_t = std::size_t;

int main() {
  index_t h = 1080;
  index_t w = 1920;

  std::vector<float> buf(h * w);

  // row-major(행 우선) 2D 뷰
  std::mdspan<float, std::extents<index_t, std::dynamic_extent, std::dynamic_extent>>
      img(buf.data(), h, w);

  img(0, 0) = 1.0f;
  img(h - 1, w - 1) = 2.0f;
}

여기서 중요한 점은 다음과 같습니다.

  • 실제 메모리는 buf가 연속으로 소유
  • img(i, j)는 내부적으로 i * w + j 같은 매핑을 수행
  • mdspan 자체는 매우 가벼워 함수 인자로 넘기기 좋음

레이아웃 선택: layout_rightlayout_left

mdspan은 레이아웃 정책으로 “어떤 차원이 연속인가”를 표현합니다.

  • std::layout_right: 마지막 인덱스가 가장 빨리 변함(대부분 C/C++의 행 우선과 잘 맞음)
  • std::layout_left: 첫 인덱스가 가장 빨리 변함(Fortran 스타일, 열 우선)

행 우선 행렬을 C++에서 가장 자연스럽게 쓰려면 보통 layout_right를 선택합니다.

#include <mdspan>
#include <vector>
#include <cstddef>

using index_t = std::size_t;

using mat2d = std::mdspan<
    float,
    std::extents<index_t, std::dynamic_extent, std::dynamic_extent>,
    std::layout_right>; // row-major

void add_bias(mat2d m, float b) {
  for (index_t i = 0; i < m.extent(0); ++i) {
    for (index_t j = 0; j < m.extent(1); ++j) {
      m(i, j) += b;
    }
  }
}

int main() {
  index_t h = 3, w = 4;
  std::vector<float> buf(h * w, 0.0f);
  mat2d m(buf.data(), h, w);
  add_bias(m, 1.5f);
}

캐시 친화 루프 순서

레이아웃이 layout_right라면 j(열)가 연속이므로 내부 루프를 j로 두는 것이 일반적으로 유리합니다.

  • layout_right: for i 바깥, for j 안쪽
  • layout_left: for j 바깥, for i 안쪽

이 단순한 규칙만 지켜도 L1/L2 캐시 효율이 크게 달라집니다.

vector<vector<T>> 에서 mdspan으로 마이그레이션

기존 코드가 중첩 벡터로 되어 있다면, 보통 아래 3단계로 옮기면 안전합니다.

  1. 저장 구조를 std::vector<T>로 변경
  2. 기존 함수 인터페이스를 2D 뷰(mdspan)로 변경
  3. 인덱싱을 a[i][j]에서 a(i, j)로 변경

예시로, 간단한 컨볼루션/블러 같은 코드를 생각해봅시다.

#include <mdspan>
#include <vector>
#include <cstddef>
#include <algorithm>

using index_t = std::size_t;

using img2d = std::mdspan<
    const float,
    std::extents<index_t, std::dynamic_extent, std::dynamic_extent>,
    std::layout_right>;

using out2d = std::mdspan<
    float,
    std::extents<index_t, std::dynamic_extent, std::dynamic_extent>,
    std::layout_right>;

void box_blur_3x3(img2d in, out2d out) {
  index_t h = in.extent(0);
  index_t w = in.extent(1);

  for (index_t i = 1; i + 1 < h; ++i) {
    for (index_t j = 1; j + 1 < w; ++j) {
      float sum = 0.0f;
      for (index_t di = 0; di < 3; ++di) {
        for (index_t dj = 0; dj < 3; ++dj) {
          sum += in(i + di - 1, j + dj - 1);
        }
      }
      out(i, j) = sum / 9.0f;
    }
  }
}

int main() {
  index_t h = 1080, w = 1920;
  std::vector<float> in_buf(h * w, 1.0f);
  std::vector<float> out_buf(h * w, 0.0f);

  img2d in(in_buf.data(), h, w);
  out2d out(out_buf.data(), h, w);

  box_blur_3x3(in, out);
}

이 구조는 다음 장점이 있습니다.

  • 입력/출력 버퍼가 연속이어서 SIMD, 프리페치, 캐시 locality에 유리
  • 함수는 소유권을 갖지 않아 테스트/재사용이 쉬움
  • const 뷰와 mutable 뷰를 타입으로 구분 가능

서브뷰와 타일링: 큰 2D를 잘게 쪼개기

메모리 최적화의 다음 단계는 “캐시에 맞게 처리 단위를 줄이는 것”입니다. 즉 타일링(블로킹)입니다.

mdspan 자체는 뷰이므로, 타일 단위 포인터 오프셋을 계산해 작은 뷰를 만들어 처리하는 패턴이 가능합니다.

#include <mdspan>
#include <vector>
#include <cstddef>

using index_t = std::size_t;

using mat = std::mdspan<
    float,
    std::extents<index_t, std::dynamic_extent, std::dynamic_extent>,
    std::layout_right>;

void add_tile(mat m, index_t i0, index_t j0, index_t th, index_t tw, float v) {
  for (index_t i = 0; i < th; ++i) {
    for (index_t j = 0; j < tw; ++j) {
      m(i0 + i, j0 + j) += v;
    }
  }
}

int main() {
  index_t h = 4096, w = 4096;
  std::vector<float> buf(h * w, 0.0f);
  mat m(buf.data(), h, w);

  constexpr index_t tile = 64;
  for (index_t i0 = 0; i0 < h; i0 += tile) {
    for (index_t j0 = 0; j0 < w; j0 += tile) {
      index_t th = (i0 + tile <= h) ? tile : (h - i0);
      index_t tw = (j0 + tile <= w) ? tile : (w - j0);
      add_tile(m, i0, j0, th, tw, 1.0f);
    }
  }
}

여기서는 별도의 서브뷰 생성 없이도 타일링 효과를 얻지만, 개념적으로는 “큰 2D를 타일 단위로 접근”하는 구조입니다. 고성능 GEMM 같은 커널에서는 이 타일 크기를 L1/L2/레지스터에 맞추어 튜닝합니다.

스트라이드/패딩을 가진 2D에도 적용

실무에서는 “연속 2D”만 있는 게 아닙니다.

  • 각 행 끝에 패딩이 들어간 이미지 버퍼(Stride가 w가 아님)
  • 메모리 정렬(alignment) 때문에 pitch가 커진 GPU/비디오 프레임

이때 mdspan은 “레이아웃 + 매핑”으로 표현할 수 있고, 구현/라이브러리 지원 범위에 따라 layout_stride를 쓰는 방식이 일반적입니다.

아래 코드는 개념 예시이며, 스트라이드가 pitch인 2D를 뷰로 다룹니다.

#include <mdspan>
#include <cstddef>

using index_t = std::size_t;

void fill_strided(float* base, index_t h, index_t w, index_t pitch) {
  // pitch는 한 행의 원소 수(패딩 포함)
  std::mdspan<
      float,
      std::extents<index_t, std::dynamic_extent, std::dynamic_extent>,
      std::layout_stride>
      m(base,
        std::extents<index_t, std::dynamic_extent, std::dynamic_extent>(h, w),
        std::layout_stride::mapping<
            std::extents<index_t, std::dynamic_extent, std::dynamic_extent>>(
            std::extents<index_t, std::dynamic_extent, std::dynamic_extent>(h, w),
            std::array<index_t, 2>{pitch, 1}));

  for (index_t i = 0; i < h; ++i) {
    for (index_t j = 0; j < w; ++j) {
      m(i, j) = 0.0f;
    }
  }
}

포인트는 “실제 메모리 레이아웃이 i * pitch + j라면, 그 규칙을 뷰에 명시해 2D 인덱싱을 유지”하는 것입니다.

mdspan을 함수 경계에 두면 좋은 이유

성능 최적화는 종종 “데이터 소유 구조”와 “알고리즘 인터페이스”를 분리하는 데서 시작합니다.

  • 소유: std::vector<T>, std::unique_ptr<T[]>, 커스텀 풀
  • 접근: std::mdspan

이렇게 하면 다음이 좋아집니다.

  • 호출자는 어떤 방식으로 메모리를 준비해도 됨(연속 버퍼만 맞추면 됨)
  • 알고리즘은 2D 인덱싱을 유지하면서도 추가 할당이 없음
  • 테스트에서 작은 버퍼를 만들어 빠르게 검증 가능

예외 없는 에러 처리까지 같이 엮으면(예: 입력 크기 검증 실패) 더 단단해집니다. 관련해서는 예외 대신 값으로 에러를 전달하는 패턴을 다룬 글인 C++23 std::expected로 예외 없이 에러처리+회수도 함께 보면 “고성능 코드에서 예외 비용/정책을 어떻게 다룰지” 감이 잡힙니다.

메모리 최적화 체크리스트

mdspan을 도입했다고 자동으로 빨라지지는 않습니다. 아래를 같이 점검해야 체감이 납니다.

1) 연속 메모리 보장

  • vector<vector<T>>는 가능하면 피하고
  • vector<T> 한 덩어리로 바꾼 뒤 mdspan으로 감싸기

2) 루프 순서가 레이아웃과 일치하는지

  • layout_right면 마지막 차원을 내부 루프로

3) 불필요한 범위 체크/분기 제거

  • 디버그 빌드에서만 검사하고 릴리즈에서는 단순 루프 유지
  • 경계 처리는 별도 패딩/가드 영역을 두는 방식도 고려

4) 타일링으로 캐시 미스 줄이기

  • 큰 2D를 그대로 훑기보다, 타일 단위로 연산

5) 데이터 정렬과 벡터화

  • 가능하면 std::assume_aligned 같은 힌트(컴파일러/플랫폼 의존)를 고려
  • 연속 버퍼는 자동 벡터화가 더 잘 걸림

흔한 함정

mdspan은 소유하지 않는다

mdspan은 포인터를 들고 있으므로, 원본 버퍼가 먼저 파괴되면 뷰는 즉시 댕글링됩니다. 함수 인자로 받을 때도 “뷰의 수명”이 아니라 “버퍼의 수명”을 기준으로 생각해야 합니다.

차원 순서를 헷갈리기 쉽다

m.extent(0)가 보통 높이, m.extent(1)가 너비가 되도록 팀 컨벤션을 정해두는 것이 좋습니다. 이미지/행렬 코드가 섞이면 i, j 의미가 뒤집혀 버그가 납니다.

ABI/표준 라이브러리 지원

프로덕션에서는 컴파일러/표준 라이브러리 버전 매트릭스를 먼저 확인하세요. 지원이 애매하면 레퍼런스 mdspan 구현을 쓰고, 나중에 std::mdspan으로 갈아타는 전략이 안전합니다.

마무리

std::mdspan은 “2D 배열을 빠르게 만드는 마법”이라기보다, 연속 메모리 기반 설계를 유지하면서도 코드 가독성과 인터페이스 품질을 올려주는 도구입니다.

  • 저장은 1D 연속 버퍼로 단순화
  • 접근은 2D 뷰로 명확화
  • 레이아웃/스트라이드/타일링으로 캐시 효율을 제어

특히 이미지 처리, 격자 기반 시뮬레이션, 행렬 연산처럼 2D 접근이 지배적인 영역에서는 mdspan을 함수 경계에 도입하는 것만으로도 구조가 깔끔해지고, 최적화 여지가 커집니다.