실수와 프로그래밍

by Joongi Kim

NBA 프로젝트를 하면서 디버깅 때문에 고생해왔고 또 지금도 고생하고 있다. BSD 라이선스가 아니라 CRAPL로 공개해야 되나 싶을 정도인데, 주로 디버깅을 하면서 코드 자체의 logical error를 제외하고 고생한 포인트들을 짚어보자면 다음과 같다.

  • sizeof()에 실제 값의 크기를 알려주는 value type을 넘겨야 하는데 실수로 *을 추가해서 포인터 크기만큼만 메모리 할당
  • uint32_t[]였던 특정 배열을 uint16_t[] 같이 원소 타입을 바꿨는데 어딘가에서는 그대로 uint32_t *로 접근하고 있음 (implicit type conversion 때문에 못 알아차리는 경우)
  • CUDA 코드와 host C++ 코드 양쪽에서 공유하지만 컴파일러 호환성 이슈로 2개의 파일에 나눠서 정의한 데이터구조가 있는데 어느 한쪽에서만 고정 크기 배열의 원소 개수를 바꾼 경우
  • C++ class의 생성자 오버로딩을 통해 기본값을 가지는 optional argument를 추가했는데, 그 클래스 내부에서 사용하는 메모리 alignment 인자값이 integer literal의 타입이 다른 오버로딩과 겹치면서 제대로 전달되지 않은 경우

    class CPUMemoryPool {
      size_t _align;
    public:
      CPUMemoryPool() : _align(64) { ... }
      CPUMemoryPool(size_t align) : _align(align) { ... }
      ...
    };
    
    class CUDAMemoryPool {
      size_t _align;
      int _flags;
    public:
      CUDAMemoryPool(int flags = 0) : _align(64), _flags(flags) { ... }
      CUDAMemoryPool(size_t align, int flags = 0) : _align(align), _flags(flags) { ... }
      ...
    };
    
    ...
    #define MEMPOOL_ALIGN (8)
    auto cpu_mp = new CPUMemoryPool(MEMPOOL_ALIGN);
    auto cuda_mp = new CUDAMemoryPool(MEMPOOL_ALIGN);
    // 의도는 두번째 생성자를 호출하는 것이었지만 컴파일러는 첫번째 생성자를 호출하도록 컴파일함.
    // 두 memory pool이 같은 크기의 메모리를 할당할 것이라 생각하고 사용했다가 epic fail...
    
  • 메모리 형식을 변환할 때 변수명을 틀린 경우 (정확히는 복붙하다가 이름 바꾸기를 빼먹은 경우...)

    struct dbarg **dbarray;
    host_mem_t dbarray_h;
    dev_mem_t dbarray_d;
    cctx->alloc_input_buffer(io_base, sizeof(void *) * num_args, dbarray_h, dbarray_d);
    dbarray = (struct dbarg *) cctx->unwrap_host_buffer(dbarray_h); // 이걸 복붙을 잘 해야 하는데...
    ...
    struct dbarg *dbarg;
    host_mem_t dbarg_h;
    dev_mem_t dbarg_d;
    cctx->alloc_input_buffer(io_base, sizeof(struct dbarg), dbarg_h, dbarg_d);
    dbarg = (struct dbarg *) cctx->unwrap_host_buffer(dbarray_h); // dbarg_h여야 함!!
    ... // dbarg에 내용을 채워넣으면서 엉뚱한 메모리를 덮어쓰는데, GPU에서 실행해보기 전까지 에러가 잡히지 않음.
    dbarray[idx] = cctx->unwrap_device_buffer(dbarg_d);
    

대충 기억나는 삽질들만 해도 이 정도인데, 코드 양이 워낙 많다보니 사소한 실수 하나하나가 모여 꽤 시간을 잡아먹는다. 그래서 요즘에는 unit test도 나름대로 도입해서 숨겨져있던 버그도 잡아내는 등 약간의 성과가 있었는데, 그럼에도 불구하고 GPU offloading 과정처럼 실제로 여러 클래스와 이기종 환경이 맞물려 돌아가야만 버그가 드러나는 경우는 여전히 unit test 기법만으로는 한계가 있다. 그런 "연동" 상황을 테스트하는 걸 따로 integration test라고도 하는 모양인데, 이걸 만들자니 꽤 손이 많이 가게 생겨서 어찌해야 하나 고민 중이다.

원래 프로그래밍에서 사람의 실수를 잡아주는 역할을 하는 게 보통 type system과 컴파일 시점에 행하는 static analysis이다. Type system을 이용하면 sizeof() 같이 강제로 내가 크기를 알려줘야 하는 경우를 없애거나 줄일 수 있고, 코드 일부에서 타입이 바뀌었을 때 컴파일러가 보다 정확하게 경고나 오류를 낼 수 있다. 문제는 NBA처럼 CPU의 메모리와 GPU의 메모리를 가상의 mapping을 통해서 복사해서 사용하는 경우 코딩 과정에서 void *가 난무하게 되고, type system을 통한 검증의 이득을 취하기 어려워진다는 점이다. (위의 마지막 예제 코드에서 host_mem_tdev_mem_t 같은 사용자 정의 타입도 실상 void *를 포장한 것에 지나지 않는다) Static analysis로 잡아낼 수 있는 오류의 종류는 아직까지는 상당히 제한적이고, 검증에 필요한 연산량도 프로그램 복잡도에 따라 기하급수적으로 커지기 때문에 여전히 활발히 연구 중인 분야이다. NBA에 바로바로 적용해서 써보기는 어렵다는 뜻이다.

마지막 사례와 같은 버그를 잡아내는 방법으로는, alloc_input_buffer() 메소드로 할당된 host-side memory 영역에서는 GPU로 복사되기 전까지 반드시 1회만 write가 발생해야 한다 이런 제약을 거는 걸 생각해볼 수 있다. 하지만 현재 x86 CPU에서 제공하는 memory access breakpoint는 하드웨어적으로 4개의 포인터로 제한되기 때문에 저렇게 임의의 넓은 영역에서 발생하는 덮어쓰기를 잡는 데 활용하기는 어렵다. valgrind와 같은 메모리 디버깅 도구도 사용하기 어려운 게, 표준 malloc()을 사용하지 않고 자체적으로 구현한 메모리 관리자를 사용하는 것이라 valgrind가 메모리 경계를 모른다. 예전에 상진형과 민장님이 소개해주신 방법은 Intel PIN이라는 binary instrumentation tool을 이용해서 dynamic analysis를 하는 것이었다. 일단 PIN에 익숙해지기만 하면 하루 이틀 정도면 앞서 언급한 것과 같은 제약을 구현할 수 있을 거라는데, 당시에 PIN을 새로 배워서 해봐야겠다는 마음의 여유가 없었더랬다. (그리고 PIN tutorial을 찾아보면 알겠지만, API 생김새가 복잡해서 간단한 일을 정말 간단하게 할 수 있는지 의심부터 먼저 든다. 게다가 이런 툴을 기존의 build chain에 넣는 것부터가 며칠짜리 삽질이 될 수도 있기에...)

그래서 내가 작년에 잠시 관심을 가졌던 게 바로 Rust 프로그래밍 언어였다. 메모리 영역의 writable reference (=ownership)을 반드시 하나의 주체(scope 또는 thread)만 가질 수 있도록 type system 수준에서 강제함으로써 안전한 프로그래밍을 하고자 만들어진 언어로, 최근 유행하는 VM 기반의 언어들과 달리 C/C++을 대체할 수 있을 정도의 low-level system programming을 할 수 있다. 하지만 이것도 복잡한 자료구조를 넓은 메모리 공간에 임의로 mapping해서 사용하려면 결국 unsafe { ... } 블록으로 감싸서 C처럼 코딩하거나 experimental 버전에 있는 기능들을 이용해야 하는 등 아직 부족한 점들이 많았다. 뭐, 그런 부족한 부분들은 버전업이 꾸준히 이뤄지면서 나아지고 있지만, 애초에 Intel DPDK와 C++ 기반으로 거대하게 쌓아놓은 코드베이스를 한꺼번에 Rust로 다시 짠다는 것부터가 불가능한 일이었다.

내 경험 상 여태껏 가장 효과적이었던 디버깅 방법은 크게 다음과 같다.

  1. 중간중간 적절히 딴짓(...)을 해가면서 코드를 정독한다. 한숨 자고 오거나 밥먹고 오면 새롭게 의심가는 부분들이 떠오르는데 그걸 몇번 반복하다보면 버그의 실체에 접근하게 된다.
  2. printf()assert()를 잔뜩 걸어놓는다. 가장 무식하고 전통적인 디버깅 방법.
  3. 의심가는 코드 영역을 주석처리해서 다른 부분들이 잘 동작하는지 확인하고 주석처리하는 영역을 점점 줄여간다.
  4. 메모리 할당·해제를 관리하는 wrapper layer가 있는 경우 wrapper에서 모든 할당과 해제를 직접 감시하도록 코드를 고쳐서 offset/length 로그를 떠본다. 적어도 내가 짠 메모리 관리자 자체의 오류는 없도록 확실히 해둔다.

gdb와 같은 디버깅 도구들은 메모리 어느 영역이 어떻게 깨졌는지 관찰하는 용도나 segmentation fault가 나는 지점을 찾아내는 용도로는 유용하지만, 근본적인 원인이 어디에 있는지 알아내기에는 역부족이다. 위에서 얘기한 것처럼 dynamic analysis가 필요한 수준이라서 디버거만으로는 한계가 있다.

아마도 NBA를 짤 때 처음부터 unit test를 도입해서 코드를 작성했다면 지금보다는 훨씬 적은 범위에서 기술적 부채를 '관리'할 수 있었으리라 생각되지만(그럼으로써 같은 실수를 해도 훨씬 적은 시간과 노력으로 고칠 수 있게 하는...), NBA 코딩을 처음 시작했을 때의 상황(NSDI 데드라인 2달 전 int main() 입력...)이나 나의 멘탈 상태를 돌이켜보면 쉽지 않은 일이었다. 급할수록 돌아가라고 했건만, C++에서 unit test를 어떤 식으로 하는 게 좋은지도 전혀 경험이 없었던 시절이라 그만큼 마음의 장벽이 높았던 것 같다. 그리고 한번 기술적 부채가 쌓이고 나니 매번 데드라인은 다가오고 졸업은 해야겠고(...) 코드를 다시 정리할 여유가 생각보다 쉽게 나지 않고 있다. NBA를 처음부터 다시 짜면 훨씬 잘 짤 수 있을 것 같은데 못내 아쉬운 부분이다. 기술적 부채를 어떻게 하면 잘 관리할 수 있는가가 앞으로도 코딩에서 손을 놓지 않는 한 계속 하게 될 고민일 듯.