- Published on
[Dev Log] FFI 최적화 2, 마샬링이라는 전보붙이는 수준에 느려터진 병목 개선
- Authors
- Name
- Logan Kim
1. C++ 는 강력하지만 불친절하다
지난 포스트에서 우리는 메인 스레드를 학살하는 코루틴(Coroutine)의 지옥도를 목격했다. 해결책은 명확했다. 개좆소같이 에이스 직원만 학살하는게 아니라, 무능한 상사들을 쳐내고 일을 골고루 나눠서 하게 해야한다는 점이다. 이걸 바로 말하면 무거운 수학 연산들을 C#에서 빼내어, 나머지 CPU 코어들에게 C++ 네이티브(Native) 플러그인 형태로 던져버리는 것이다.
사실 주니어라면 여기까지 온 것만 해도 칭찬할만 하다. 사실 이 FFI 최적화는 디버그도 힘들고 안해도 동작 자체는 문제가 없기 때문이다. 구글링해서 **[DllImport]**를 스크립트 상단에 붙이고, 10,000개의 객체 데이터를 그 글에 존재하는 친절한 구조체 마샬링 기법으로 잘 포장해서 C++로 넘긴 뒤 프레임이 오르기를 기대한다.
하지만 현실은 그렇게 관대하지않다. 위에 기법은 그저 C#에 있는 데이터를 C++로 넘기는 기법인거지, 성능최적화는 아니다. 이렇게 해놓고 기대에 가득차서 유니티 프로파일러를 켠다면 이번에는 메인 스레드의 차트가 내 주식계좌가 이랬으면 좋겠다는 느낌처럼 빨간색 상승장에서 요동치고있다. 분명 연산은 C++이 멀티스레드로 하고 있는데 대체 왜 이러는 걸까?
마샬링(Marshalling) 세금: 온프레미스의 무식한 택배 배달
문제는 C# (Managed)과 C++ (Unmanaged)이라는 두 세계가 서로 데이터를 주고받는 '물류 시스템'에 있다.
초보자들이 흔히 사용하는 데이터 전달 방식(값 복사, Value Copy)은 마치 구시대적인 온프레미스(On-premise) 서버의 하드디스크 배달과 같다. C# 영역에서 10,000개의 좌표 데이터를 처리해 달라고 C++에 요청할 때, 시스템은 이 데이터들을 C++이 이해할 수 있는 규격의 구조체로 일일이 변환하고, 메모리에 새로 할당해서 꽁기꽁기 싸맨 다음, 국경을 넘어 C++ 진영으로 택배를 보낸다.
C++은 이 택배 박스를 뜯어서 연산한 뒤, 다시 새로운 박스에 결과를 포장해서 C#으로 돌려보낸다. 이 무식한 포장과 배달 과정을 '마샬링(Marshalling)'이라고 부른다. 연산 자체는 0.1밀리초 만에 끝났는데, 박스를 싸고 푸는 택배 상하차 작업(마샬링 세금)에 10밀리초를 태워버리는 미친 짓거리가 벌어지고 있는 것이다. 박스가 쌓일수록 가비지 컬렉터(GC)는 폭주한다.
3. 아키텍트의 해결책: 클라우드 S3와 메모리 포인터(Zero-Copy)
데이터를 무식하게 포장해서 넘기는 짓을 멈춰야 한다. 현대 IT 인프라에서 대용량 데이터를 공유할 때 하드디스크를 오토바이 퀵으로 보내는가? 아니다. 클라우드(AWS S3)에 데이터를 한 번만 올려두고, 그 데이터에 접근할 수 있는 URL 링크만 카톡으로 띡 던져준다.
메모리 아키텍처도 정확히 이 클라우드 방식을 따라야 한다. 이것이 바로 제로 카피(Zero-Copy) 모델이다.
C#에서 10,000개의 데이터를 담을 거대한 배열(Array)을 딱 한 번만 할당한다. 여기가 우리의 클라우드 저장소다. 그리고 C++에게 데이터를 포장해서 보내는 대신, "이 데이터는 메모리 0x1A2B 번지에 있으니, 네가 직접 와서 읽고 거기에 덮어써라"라며 메모리 주소(Pointer) 하나만 던져준다. 포인터의 크기는 고작 8바이트(64비트 기준)다. 수만 개의 데이터를 넘기든 수십만 개를 넘기든, 통행료는 언제나 8바이트로 고정된다.
4. C#이 겁주는걸 극복해야 한다. unsafe와 fixed
C#은 알다시피 마이크로소프트가 주도해서 만들었다. 솔직히 아무리 잘쳐줘도 좋은언어라고 보긴 어렵다. C의 형상을 한 자바라고 보는게 적절하다. 뜬금없이 왜 이런말을 하냐면, C++같은 네이티브언어에서 C#으로 데이터를 옮기는건 C++에서 자바로 데이터를 옮기는것같이 불안전하다. 그래서 마이크로소프트는 C# 컴파일러에 이런 작업을 어린이들이 락스병에 손대는걸 막듯이 unsafe라는 옵션으로 막아뒀다. 그리고 이걸 풀었을경우, 락스병을 들고다니는 어린이를 본것처럼 겁을준다.
그렇지만 개발자라면 이런 컴파일러에 구린 협박정도는 이겨내야한다. 그리고 추가로 설명이 필요한게 fixed이다.
유니티의 가비지 컬렉터(GC)는 온실 속 화초처럼 메모리를 스스로 정리하며 돌아다닌다. 클라우드 주소를 C++에 넘겨줬는데, GC가 멋대로 메모리 위치를 이사시켜 버리면 C++은 허공에 대고 연산을 하게 된다. fixed는 이런 경우를 대비해서 쓰게된다. 예제코드는 이런식이다.
// 데이터 이동(마샬링)이 단 1바이트도 발생하지 않는 Zero-Copy FFI 호출
private unsafe void ProcessNativeBatch()
{
// fixed: 가비지 컬렉터(GC)에게 "내가 C++ 다녀올 때까지 이 배열 위치 절대 옮기지 마!" 라고 잡도리를 시전한다.
fixed (PathJobData* ptr = _nativeJobArray)
{
// C++ 함수에는 오직 배열의 시작 주소(Pointer) 하나만 띡 던져준다. (URL 공유)
ComputePathsBatchNative(ptr, _nativeJobArray.Length, Time.time);
}
}
이 짧은 코드가 실행되는 순간, C++ 스레드 풀은 클라우드(공유 메모리)에 직접 접근하여 가용한 모든 코어를 동원해 10,000개의 좌표를 빛의 속도로 덮어버린다. C#은 배열을 복사할 필요도 없고, 결과를 돌려받을 필요도 없다. 그냥 제자리에 있는 데이터를 꺼내 쓰기만 하면 끝이다.
5. 맺음말
최적화는 코드 몇 줄을 줄이는 얄팍한 테크닉이 아니다. 데이터가 어디에 적재되고, 어떻게 흐르며, 누가 그 소유권을 가지는지 시스템 밑바닥의 물류를 설계하는 작업이다.
온프레미스 시대의 하드디스크 배달부로 남을 것인가, 아니면 메모리 포인터라는 URL 하나로 10,000개의 객체를 통제하는 클라우드 시대의 아키텍트로 거듭날 것인가. 선택은 당신의 몫이다.
FFI에 대해 하고싶은 말은 많지만, 이 정도로 줄이고 다음에는 믿고있던 유니티가 뒤통수치는 케이스를 방지하는것으로 최적화 할 수 있는 방법을 알아보자