Unity 검색

다루는 주제
공유

Is this article helpful for you?

Thank you for your feedback!

이 게시물에서는 유니티의 새로운 데이터 지향 기술 스택(DOTS)을 간략히 소개하여 현재까지의 성과와 향후 계획을 공유하고자 합니다. 앞으로도 블로그를 통해 DOTS에 대한 내용을 더 많이 공유드리겠습니다.

C++에 대해 먼저 살펴보겠습니다. C++는 여러분이 아시는 대로 현재 Unity 엔진 개발에 사용되는 언어입니다.

많은 고급 게임 프로그래머가 개발 중에 직면하게 되는 과제 중 하나는 대상 프로세서가 이해할 수 있는 명령어로 실행 파일을 작성하여 해당 파일을 실행하면 바로 게임이 실행되어야 한다는 것입니다.

성능에 민감한 코드 부분에 어떤 최종 명령어를 작성해야 하는지는 이미 잘 알고 있습니다. 다만 이런 로직을 합리적으로 설명함으로써 의도한 대로 명령어가 생성되는지 믿고 검증할 만한 간단한 방법이 필요할 뿐입니다.

이러한 작업에서 C++는 최적화된 언어라고는 할 수 없습니다. 예를 들어 루프를 벡터화하려고 할 때 컴파일러에서 루프가 벡터화되지 않는 문제가 발생할 수 있는 원인으로는 백만 가지가 있습니다. 혹은 당장은 벡터화가 되더라도 겉보기에 별 문제가 없어 보이는 변경을 했음에도 다음날은 벡터화가 되지 않을 수도 있습니다. C/C++ 컴파일러로 코드를 벡터화시키는 작업 자체가 쉬운 일이 아닙니다.

그래서 중요하다고 생각하는 모든 항목을 모두 반영할 수 있는 “기계어 코드를 생성하는 합리적으로 편한 방식”을 직접 만들어 보기로 결정했습니다. C++ 설계를 유니티에 더 유리하게 약간만 변경하는 것이 조금 더 효율적인 방법일 수도 있었지만 그 과정에 걸리는 시간을 모든 설계가 가능한 툴체인에 투자하여 게임 개발자가 직면하는 문제 해결을 위해 정확히 설계하고자 했습니다.

유니티가 중요하다고 간주한 항목은 무엇이었을까요?

  • 성능은 정확성만큼이나 중요합니다. “루프가 어떤 이유로든 벡터화되지 않을 때 ‘코드가 딱 8배 느려지긴 했지만 값은 정확히 산출되니까 괜찮아!’가 아니라 이건 컴파일러 오류 때문이야”라고 말할 수 있어야 합니다.
  • 크로스 아키텍처: iOS를 대상으로 할 때든 Xbox를 대상으로 할 때든 관계없이 작성하는 입력 코드는 동일해야 합니다.
  • 코드 변경 시 모든 아키텍처용으로 생성되는 기계어 코드를 간편하게 볼 수 있는 적절한 반복 루프가 있어야 합니다. 기계어 코드 “뷰어”를 통해 기계어 명령어가 어떻게 작동하는지 잘 보여줄 수 있어야 합니다.
  • 안전: 안전 자체를 최우선으로 여기는 개발자는 거의 없습니다. 그러나 메모리 손상이 거의 없다는 장점은 Unity의 가장 탁월한 특징 중 하나라고 할 수 있습니다. 코드 실행 시 읽기/쓰기 한도를 초과하거나 null을 역참조할 경우 오류를 분명하게 보여주는 메시지를 표시하는 모드가 있어야 합니다.

이제 중요하다고 생각하는 항목에 대해 알아보았으므로, 다음은 기계어 코드 생성기의 입력 언어를 결정할 차례입니다. 다음과 같은 옵션이 있다고 가정해 봅시다.

  • 커스텀 언어
  • C 또는 C++의 일부 채택/부분 집합
  • C# 부분 집합

여기에서 C#이 옵션으로 사용되는 이유는 뭘까요? 성능에 가장 민감한 내부 루프에 필요해서일까요? 맞습니다. C#에는 다음과 같이 Unity에서 활용할 수 있는 우수한 기능이 포함되어 있으므로 C#을 선택하는 것은 매우 당연한 결과입니다.

  • C#은 현재 Unity 사용자가 이미 사용하고 있는 언어입니다.
  • 편집/리팩터링뿐만 아니라 디버깅 측면에서 우수한 IDE 툴입니다.
  • C#-> IL 컴파일러(Microsoft의 RoslynC# 컴파일러)가 이미 존재하기 때문에 자체적으로 개발할 필요 없이 있는 것을 그대로 사용하면 됩니다.
  • Unity로 IL 수정을 많이 해보았기 때문에 실제 프로그램에서 코드를 생성하고 포스트 프로세싱을 수행하는 것이 어렵지 않습니다.
  • C++에서 겪을 수 있는 문제(헤더 포함, PIMPL 패턴, 긴 컴파일 시간)가 상당수 해소됩니다.

저는 개인적으로 C#으로 코드를 작성하는 것을 매우 좋아합니다. 그러나 성능 면에서 기존의 C#은 그다지 놀라운 언어가 아니었습니다. 하지만 C# 언어 팀, 스탠다드 라이브러리 팀, 런타임 팀은 지난 2년 동안 커다란 진전을 이뤄냈습니다. 여전히 C# 언어로는 메모리에서 데이터를 배치할 위치와 방법을 제어할 수 없습니다. 앞으로 이런 부분에 있어 성능 개선이 필요할 것입니다.

게다가 스탠다드 라이브러리는 “힙에 위치한 오브젝트” 및 “다른 오브젝트에 대한 포인터 레퍼런스가 있는 오브젝트” 중심입니다.

하지만 성능에 민감한 코드 부분을 작업할 때 스탠다드 라이브러리(Linq, StringFormatter, List, Dictionary) 대부분을 포기하고, 할당(클래스 없이 구조만), 반사, 가비지 컬렉터 및 가상 호출을 허용하지 않고, 사용을 허용할 새로운 컨테이너(NativeArray 등) 몇 가지만 추가할 수 있습니다. 그러면 C# 언어의 나머지 부분이 무척 보기 좋아집니다. 경로 추적기 토이 프로젝트(path tracer toy project)의 예를 보려면 Aras의 블로그를 참조하세요.

이 부분 집합으로도 핫 루프에서 필요한 모든 작업을 수월하게 진행할 수 있습니다. C#의 유효한 부분 집합이 되므로 이 자체를 일반적인 C#처럼 실행할 수 있습니다. 오류 메시지를 통해 접근 한도 초과 오류를 잡아낼 수 있고, 디버거의 지원을 받을 수 있으며, C++로 작업할 때는 불가능하다고 여겨지던 빠른 컴파일 속도도 실현할 수 있습니다. 이 부분 집합을 가리켜 고성능 C# 또는 HPC#(High Performance C#)이라고 부릅니다.

버스트 컴파일러 활용 효과

유니티는 버스트(Burst)라고 하는 코드 생성기/컴파일러를 개발했습니다. 버스트는 Unity 2018.1부터 프리뷰 패키지로 제공되었습니다. 아직도 개선이 필요한 부분이 있기는 하지만 컴파일러 수준은 현재로서도 충분히 만족스럽습니다 .

버스트가 어떤 부분에서는 C++를 앞서지만 아직 C++에 뒤처지는 부분도 있습니다. 후자의 경우는 성능 버그로 보이며 이는 해결 가능한 것으로 보입니다.

그러나 성능 비교만으로는 충분하지 않습니다. 그 성능을 확보하기 위해 무엇을 했는지도 중요합니다. 예를 들면, 현재 C++ 렌더러의 C++ 컬링 코드를 복사해서 버스트에 포팅했습니다. 성능은 동일했지만 C++ 버전으로 C++ 컴파일러를 실제로 벡터화하기 위해서는 엄청난 노력이 필요했습니다. 버스트 버전에서는 이러한 노력이 약 4분의 1로 줄었습니다.

“성능에 가장 민감한 코드를 C#으로 바꿔야 한다”는 주장을 유니티 내부의 모두가 즉각적으로 받아들이지는 않았습니다. 대부분은 C++를 사용할 때 “더 직접적으로 기계어 코드로 컴파일을 할 수 있다”고 생각합니다. 하지만 그렇지 않습니다. C#을 사용하면 소스 컴파일에서부터 기계어 코드 생성에 이르기까지 전 과정을 완전히 제어할 수 있기 때문에 마음에 들지 않는 부분이 있다면 직접 그 부분을 들여다보고 수정할 수 있습니다.

앞으로 더디더라도 확실하게 C++로 작성된 성능에 민감한 코드 부분을 모두 HPC#으로 포팅할 것입니다. 이렇게 하면 목표로 하는 성능을 구현하기가 쉬워지고 버그를 더 자세하게 작성할 수 있게 되며, 작업이 용이해집니다.

다음은 Burst Inspector의 스크린샷입니다. 아래와 같이 다양한 버스트 핫 루프(hot loop)에 어떤 어셈블리 명령어가 생성되었는지 쉽게 확인할 수 있습니다.

Unity 사용자는 매우 다양합니다. 메모리의 ARM 64비트 명령어 세트 전체에 대해 알고 있는 사용자가 있는가 하면, 컴퓨터 공학 관련 박사 학위가 없이도 즐겁게 게임을 제작하는 사용자도 있습니다.

엔진 코드를 실행하는 데 소요되는 프레임 시간(보통 90% 이상)이 짧아지면 모든 사용자에게 혜택이 돌아갑니다. 에셋 스토어 패키지 제작자가 HPC#을 도입하면 에셋 스토어 패키지 런타임 코드 실행 시간이 단축됩니다.

또한 고급 사용자의 경우 HPC#으로 고성능의 코드를 자체적으로 작성하는 이점을 누릴 수 있습니다.

더욱 세분화된 최적화

C++로는 프로젝트의 각 부분에 맞게 각기 다른 최적화 균형점을 찾으라는 명령을 컴파일러에 내리기가 무척 어렵습니다. 최선의 방법은 파일별로 최적화 수준을 세부적으로 지정하는 것입니다.

버스트는 프로그램 내의 단 하나의 메서드를 핫 루프로의 진입점, 즉 입력으로 가지도록 설계되었습니다. 버스트는 해당 함수와 그 함수가 호출하는 모든 것을 컴파일합니다(참고: 가상 함수나 함수 포인터는 허용되지 않음).

버스트는 프로그램에서 상대적으로 작은 부분에 대해서만 작업을 수행하므로 최적화 수준을 11로 설정했습니다. 그런데 버스트는 거의 모든 함수 호출 부분을 인라인 형태로 생성합니다.. 함수 인수에 대한 정보는 인라인 형식일 때 더 자세히 알 수 있으므로 Optimizations의 체크 박스가 해제되어 있지 않다면 해제하십시오.

일반적인 멀티스레딩 문제 해결 지원

C#도, C++도 개발자가 스레드 세이프(thread-safe) 코드를 작성하는 데 크게 도움이 되지는 않습니다.

게임 소비자가 2개 이상의 하드웨어 코어를 가지게 된지 10년이 넘은 지금까지도 멀티 코어를 효과적으로 사용하는 프로그램을 제공하기란 매우 어렵습니다.

데이터 경합(Data race), 비결정론적 분명성(nondeterminism), 교착(deadlock) 상태 모두 멀티스레드 코드 포함하는 것을 어렵게 합니다. 따라서 “함수와 함수가 호출하는 모든 것이 전역 상태를 읽거나 쓰지 않도록 하는” 것과 같은 기능이 필요합니다. 규칙을 지키지 않는다면, “모든 프래그래머가 지켜야할 가이드라인”이 아닌 컴파일 오류로 간주되어야 합니다. 버스트 컴파일러가 이러한 컴파일 오류를 제공합니다.

원하는 모든 데이터 변환을 잡(job)으로 분할하기 위해 Unity 사용자와 유니티 직원 모두에게 “잡 시스템을 위한(jobified)” 코드를 작성할 것을 권장합니다. 이로써 각각의 잡이 부작용 없이 “기능”하며 잡이 실행되는 읽기 전용 버퍼와 읽기/쓰기 버퍼가 명시적으로 지정됩니다. 다른 데이터로 접근을 시도할 경우 컴파일러 오류가 발생합니다.

잡 스케줄러는 작업자의 잡이 실행되는 동안에는 아무도 읽기 전용 버퍼에 쓸 수 없게 하고, 아무도 읽기/쓰기 버퍼에서 읽을 수 없게 합니다.

이러한 규칙을 지키지 않도록 잡을 예약하면 매번 런타임 오류가 발생합니다. 이러한 오류 메시지 경쟁 상태(race condition)로만 한정되지 않습니다. 버퍼 A에 쓰기를 수행할 잡을 이미 예약했는데, 그 이후 버퍼 A로부터 읽기를 수행하는 잡을 예약하려고 할 경우, A로부터 읽기를 예약하려면 이전 작업을 종속 관계로 명시해야 한다는 오류 메시지가 표시될 것입니다.

실제로 이런 안전 메커니즘 덕분에 많은 버그가 커밋되기 전에 발견되어 모든 코어를 효율적으로 사용할 수 있습니다. 교착 상태나 경쟁 상태를 코딩하는 것이 불가능해집니다. 결과적으로 얼마나 많은 스레드가 실행 중이든 혹은 다른 프로세스가 단일 스레드를 얼마나 자주 중단시키든 관계없이 결정론적 분명성이 보장됩니다.

전체 스택 해킹

이러한 모든 컴포넌트를 해킹할 수 있게 되면 컴포넌트 상호 간 인지가 가능해집니다. 예를 들어, 포인터 2개가 동일한 메모리를 참조(앨리어싱)하지 않는다고 컴파일러가 보장할 수 없을 경우에는 벡터화가 일어나지 않습니다.  이미 알고 있듯이 두 NativeArray 간에 앨리어싱 현상이 일어나지 않는 이유는 컬렉션 라이브러리를 작성했기 때문입니다. 그래서 해당 내용을 버스트에서 활용할 수 있습니다. 이렇게 하면 어레이 포인터 2개가 동일한 메모리를 참조하는 문제가 발생할까 우려되어 최적화 작업을 포기하는 일은 없을 것입니다.

이와 유사하게 유니티는 Unity.Mathemetics라는 math 라이브러리를 작성했으며, 버스트는 이 라이브러리에 매우 익숙합니다. (향후에는) math.sin() 등에 대해 정확도를 희생하면서 최적화를 수행하는 것(accuracy sacrificing optimization)이 가능할 것입니다. 버스트에 있어 math.sin()은 컴파일해야 할 여느 C# 메서드와는 다르기 때문에 버스트는 sin()의 삼각 함수적 속성에 대해 이해하고, x의 작은 값에 대해 sin(x) == x임을 이해하며(버스트에서 증명이 가능할 수 있음), 약간의 정확도 희생 시 Taylor 시리즈 확장으로 대체 가능함을 이해할 것입니다. 크로스 플랫폼 및 아키텍처 부동 소수점 결정론적 분명성 또한 버스트를 통해 달성할 수 있을 것으로 보이는 향후 목표입니다.

엔진 코드와 게임 코드 – 동일 언어 사용

HPC#으로 Unity 런타임 코드를 작성하게 되면서 엔진과 게임을 같은 언어로 작성하게 되었습니다. 이제 HPC#으로 변환한 런타임 시스템을 소스 코드로 배포할 예정입니다. 누구나 이 코드를 학습하면서 개선하고 커스터마이즈할 수 있습니다. 여러분들이 유니티 개발팀보다 더 나은 파티클 시스템, 물리 시스템 또는 렌더러를 구현하는 것을 방해하지 않도록 공평한 경쟁의 장을 마련하겠습니다. 많은 분들이 이러한 혜택을 누릴 수 있기를 바랍니다. 당사 내부의 개발 프로세스가 사용자 여러분의 개발 프로세스와 크게 다르지 않도록 함으로써 유니티는 사용자가 느끼는 어려움에 더 깊이 공감하게 될 것이며, 따라서 서로 다른 2가지 워크플로 대신 동일한 하나의 워크플로를 개선하는 데 모든 노력을 기울이겠습니다.

이후에는 DOTS에서 또 하나 중요한 기능인 엔티티 컴포넌트 시스템에 대해 다뤄보겠습니다.

2019년 2월 26일 엔진 & 플랫폼 | 10 분 소요

Is this article helpful for you?

Thank you for your feedback!

다루는 주제