Version: 2022.1
언어: 한국어
일반 최적화
에셋 로드 지표

특별 최적화

이전 섹션에서는 모든 프로젝트에 적용 가능한 최적화에 대해 설명했지만, 이 섹션에서는 프로파일링 데이터를 모으기 전에 적용되지 말아야 할 최적화에 대해 자세하게 설명합니다. 최적화를 수행하는 것은 노동 집약적이며, 성능을 위해 코드의 말끔함 또는 유지보수의 용의성을 타협할 수 있으며, 또는 어느 정도 규모일 때만 나타나는 문제를 해결할 수도 있기 때문입니다.

다차원 배열 vs 가변 배열

StackOverflow 항목에 설명되어 있듯이, 다차원 배열은 함수 호출이 필수이기 때문에, 이를 반복하는 것보다는 가변 배열을 반복하는 것이 일반적으로 더 효율적입니다.

참고:

  • 이것을 배열의 배열이며, type[x,y] 대신 type[x][y]로 명시됩니다.

  • ILSpy 또는 유사 툴을 사용하여 다차원 배열에 액세스할 때 생성되는 IL를 검사하면 이를 파악할 수 있습니다.

Unity 5.3 버전에서 프로파일링 했을 때, 3차원의 100x100x100 배열을 100번 완벽히 연속 반복했을 경우 다음과 같은 시간이 소요되었으며, 이는 테스트를 10번 이상 수행해서 평균을 계산했습니다.

배열 종류 전체 시간 (100회 반복)
일차원 배열 660ms
가변 배열 730ms
다차원 배열 3,470ms

추가 함수 호출 비용은 다차원 배열 vs 일차원 배열을 액세스 하는 비용 차이에서 확인할 수 있으며, 간결하지 않은 메모리 구조를 반복하는 비용은 가변 배열 vs 일차원 배열의 비용 차이에서 확인할 수 있습니다.

위에서 설명했듯이, 추가 함수 호출 비용은 간결하지 않은 메모리 구조를 사용하여 든 비용보다 훨씬 더 많이 듭니다.

성능에 특히 민감한 작업의 경우, 일차원 배열 사용을 권장합니다. 이 외에 다차원의 배열이 필요한 모든 경우에는, 가변 배열을 사용하시기 바랍니다. 다차원 배열을 사용해선 안 됩니다.

파티클 시스템(Particle System) 풀링

파티클 시스템 풀링에는 최소 3,500바이트 이상의 메모리가 소모된다는 것을 유의하시기 바랍니다. 메모리 소모는 파티클 시스템에 활성화 된 모듈의 수에 따라 늘어납니다. 이 메모리는 파티클 시스템이 비활성화된 경우에는 해제되지 않습니다. 파티클 시스템이 파괴됐을 경우에만 해제됩니다.

Unity 5.3일 경우에, 대부분의 파티클 시스템 설정은 이제 런타임 시 조정할 수 있습니다. 여러 가지 다양한 파티클 효과를 풀링해야 하는 프로젝트의 경우, 파티클 시스템의 설정 파라미터를 데이터 저장 클래스나 구조체로 추출하는 것이 더 효율적일 수 있습니다.

파티클 효과가 필요할 경우, “일반” 파티클 효과 풀에서 필요한 파티클 효과 오브젝트를 공급할 수 있습니다. 그 다음 설정 데이터를 오브젝트에 적용하여 원하는 그래픽 효과를 얻을 수 있습니다.

이것은 주어진 씬에서 사용되는 파티클 시스템의 가능한 모든 배리언트와 설정을 풀링하는 것보다 훨씬 더 메모리 효율적이지만 상당한 엔지니어링 노력이 필요합니다.

업데이트 관리자

내부적으로 Unity는 Update, FixedUpdate, LateUpdate 같은 콜백 함수와 관계 있는 오브젝트 리스트를 추적합니다. 리스트 업데이트가 계속 이뤄지도록 하기 위해 이 리스트는 intrusively-linked 리스트로 남아 있습니다. MonoBehaviours는 활성화 되었을 경우 리스트에 추가되고, 비활성화 되었을 경우 리스트에서 삭제됩니다.

콜백이 필요한 MonoBehaviour에 적절한 콜백 함수를 손쉽게 추가할 수 있어 편리하지만 콜백 수가 늘어나면 비효율적입니다. 네이티브 코드에서 관리되는 코드 콜백을 호출하는 데에는 적지만 매우 중요한 오버헤드가 발생합니다. 이로 인해 대량의 프레임당 메서드를 호출할 때 프레임 시간이 저하되고 다수의 MonoBehaviour를 포함하는 프리팹을 인스턴스화할 때 인스턴싱 시간이 저하됩니다. (참고: 인스턴스화 비용은 프리팹의 각 컴포넌트에 Awake 및 OnEnable 콜백을 호츨하는 성능 오버헤드로 인해 발생합니다.)

프레임당 콜백이 있는 MonoBehavious의 수가 수백 또는 수천으로 증가할 경우, 이러한 콜백을 제거하고 MonoBehaviour(또는 스탠다드 C# 오브젝트도)를 글로벌 관리자 싱글톤에 연결하는 것이 유리합니다. 글로벌 관리 싱글톤은 Update, LateUpdate 및 기타 콜백을 관련 오브젝트에 보낼 수 있습니다. 이 경우 오브젝트들이 별다른 동작이 없을 때 코드가 콜백 받는 것을 스마트하게 해제하고, 이것으로 프레임당 호출돼야 하는 함수의 전체 수가 감소하는 추가적인 이득이 있습니다.

절약을 가장 많이 할 수 있는 방법은 보통 거의 실행하지 않는 콜백을 제거하는 것입니다. 다음과 같은 의사 코드를 고려하십시오.

void Update() {
    if(!someVeryRareCondition) { return; }
// … some operation …
}

위와 유사한 업데이트 콜백이 포함된 다수의 MonoBehaviour가 있을 경우 업데이트 콜백을 실행하는데 소요되는 시간 중 상당 부분이 즉시 종료되는 MonoBehaviour 실행을 위해 네이티브 코드 및 관리되는 코드 도메인을 전환하는 데에 사용됩니다. someVeryRareCondition가 true일 때에만 이러한 클래스가 글로벌 업데이트 매니저에 등록하고 이후 해제할 경우 코드 도메인 전환 및 희귀한 조건 평가에 소요되는 시간을 절약할 수 있습니다.

업데이트 관리자에서 C# 델리게이트 사용

이러한 콜백을 실행하기 위해 플레인 C# 델리게이트를 사용하고자 할 수 있습니다. 하지만 C# 델리게이트 구현은 낮은 비율의 구독과 구독 취소 및 적은 수의 콜백에 최적화되어 있습니다. C# 델리게이트는 한 번 콜백이 추가 또는 제거될 때마다 해당 콜백 리스트 전체를 깊게 복사 합니다. 콜백 리스트가 많거나, 하나의 프레임당 콜백의 구독/구독 취소하는 수가 많을 경우 내부 Delegate.Combine 메서드의 성능이 순간적으로 크게 나빠집니다.

추가/제거가 빈번히 일어날 경우, 델리게이트 대신 빠른 삽입/제거를 위해 만들어진 데이터 구조를 사용하는 것을 고려해 보십시오.

스레드 컨트롤 로딩

Unity는 개발자가 데이터를 로드하기 위해 사용되는 배경 스레드의 우선 순위를 조정하는 것을 허용합니다. 이는 에셋 번들을 배경의 디스크에 스트리밍할 때 특히 중요합니다.

메인 스레드와 그래픽스 스레드의 우선 순위는 ThreadPriority.Normal입니다. 더 높은 우선 순위를 가지는 스레드는 메인/그래픽 스레드를 선점하며 프레임 속도의 끊김 현상을 발생시키는 반면, 우선 순위가 낮은 스레드는 그렇지 않습니다. 여러 스레드가 메인 스레드와 우선 순위가 동일할 경우 CPU는 모든 스레드에 동일한 시간을 주려고 합니다. 그 결과 여러 배경 스레드가 에셋 번들 압축 풀기 등과 같이 용량이 큰 작업을 수행할 경우 일반적으로 프레임 속도가 느려지는 현상이 발생합니다.

현재 세 곳에서 우선 순위를 조정할 수 있습니다.

Resources.LoadAsyncAssetBundle.LoadAssetAsync와 같은 에셋 로딩 호출의 디폴트 우선 순위는 Application.backgroundLoadingPriority 설정에서 정할 수 있습니다. 앞서 설명했듯이, 이 호출은 메인 스레드가 에셋을 통합하는 데 사용하는 시간 양도 제한합니다(참고: 대부분의 Unity 에셋 타입은 메인 스레드로 “통합”돼야 합니다. 통합 과정에서 에셋 초기화가 완료되며 특정 스레드 세이프 작업이 수행됩니다. 작업에는 Awake 콜백 등과 같은 스크립팅 콜백 호출이 포함됩니다. 에셋 로딩이 프레임 타임에 미치는 영향을 제한하는 방법에 대한 자세한 내용은 “자원 관리(Resource Management)” 가이드를 참조하십시오.

둘째, 각각의 UnityWebRequest 요청과 마찬가지로 각각의 비동기 에셋 로딩 작업은 AsyncOperation 대상을 반환하여 작업을 모니터 및 관리하도록 합니다. AsyncOperation 대상은 개별 작업의 우선 순위를 수정하는 데 사용할 수 있는 우선순위 프로퍼티를 제공합니다.

마지막으로, WWW.LoadFromCacheOrDownload의 호출에서 반환된 WWW 객체는 threadPriority 프로퍼티를 제공합니다. WWW 객체은 기본값으로 Application.backgroundLoadingPriority 설정을 자동으로 사용하지는 않습니다 – WWW 객체는 항상 기본값이 ThreadPriority.Normal입니다.

데이터 압축 해제 및 로드하는 데 사용되는 엔진 내부 시스템은 API마다 다르다는 점을 유의하십시오. Resources.LoadAsyncAssetBundle.LoadAssetAsync는 로딩 스레드를 담당하고 자체 속도 제한을 수행하는 Unity의 내부 PreloadManager 시스템에서 작동됩니다. UnityWebRequest는 전용 스레드 풀을 사용합니다. 요청이 생성될 때마다 WWW는 완전히 새로운 스레드를 생성합니다.

다른 모든 로딩 메커니즘은 대기열 시스템이 내장되어 있는 반면 WWW는 그렇지 않습니다. 매우 많은 수의 압축 에셋 번들에서 WWW.LoadFromCacheOrDownload를 호출할 경우 동일한 수의 스레드가 생성되며, 이 스레드는 CPU 시간에 대해 메인 스레드와 경쟁합니다. 그 결과 프레임 속도가 끊어지는 현상이 쉽게 발생할 수 있습니다.

따라서 WWW를 사용하여 에셋 번들을 로드하고 압축 해제할 때, 생성되는 각각의 WWW 오브젝트의 threadPriority에 해당하는 적절한 값을 설정하는 것이 가장 좋습니다.

매스 오브젝트 이동 & CullingGroups

트랜스폼 조작에 대한 섹션에서 언급했듯이 큰 트랜스폼 계층 구조를 이동할 경우 변경 메시지 증가로 인해 CPU 사용량이 상대적으로 많이 듭니다. 하지만 실제 개발 환경에서는 계층 구조를 적당한 수의 게임 오브젝트로 떨어뜨리는 것이 불가능한 경우가 있습니다.

동시에 사용자가 눈치채지 못할 동작을 제거하면서 게임 월드에 대한 신뢰성을 유지할 수 있을 정도의 동작만 실행하는 것은 좋은 개발 방향입니다. 예를 들어, 캐릭터 수가 많은 씬에서 화면 위에 있는 캐릭터의 메시 스키닝과 애니메이션 중심의 트랜스폼 이동만 실행하는 것이 더 최적입니다. 스크린 밖의 캐릭터 시뮬레이션의 시각적 요소를 계산하는 데 CPU 시간을 낭비할 이유가 없습니다.

Unity 5.1에서 처음 소개된 API로 이런 문제를 깔끔하게 해결할 수 있습니다. CullingGroups.

씬에 있는 여러 그룹의 게임 오브젝트를 직접 조작하는 대신 CullingGroup에 있는 한 BoundingSphere 그룹의 Vector3 파라미터를 조작하도록 시스템을 변경합니다. 각각의 BoundingSphere는 단일 게임 로지컬 엔티티의 월드 공간 포지션에 대한 신뢰할 만한 저장소 역할을 하고, 엔티티가 CullingGroup 메인 카메라의 절두체 내부 또는 근처로 이동할 때 콜백을 받습니다. 이 콜백은 엔티티가 보일 때만 실행해야 하는 동작을 관리하는 코드 또는 컴포넌트(Animator 등)를 활성화/비활성화하는 데 사용됩니다.

메서드 호출 오버헤드 감소

C#의 문자열 라이브러리는 추가적인 메서드 호출을 단순 라이브러리 코드에 추가하는 비용에 대해 훌륭한 연구 사례를 제공합니다. 빌트인 문자열 API String.StartsWithString.EndsWith에 관한 섹션에서는, 원치 않는 로케일이 강제 억제돼도 직접 코딩한 대체 코드가 빌트인 메서드보다 10100배 빠르다고 언급되어 있습니다.

이렇게 성능 차이가 발생하는 가장 큰 이유는 부가적인 메서드 호출을 빠르게 반복하는 내부 루프에 추가하는 비용 때문입니다. 호출된 각각의 메서드는 메서드의 주소를 메모리에 저장하고 다른 프레임을 스택에 푸시해야 합니다. 이러한 작업은 비용이 없지않지만 대부분의 코드에서 무시할 정도로 충분히 작습니다.

하지만 빠르게 반복하는 루프에서 작은 메서드를 실행할 때 부가적인 메서드 호출을 도입하면서 추가된 오버헤드는 중요하며 심지어 우세할 수 있습니다.

다음의 두 가지 단순 메서드를 고려하십시오.

예제1:

int Accum { get; set; }
Accum = 0;

for(int i = 0; i < myList.Count; i++) {
    Accum += myList[i];
}

예제2:

int accum = 0;
int len = myList.Count;

for(int i = 0; i < len; i++) {
    accum += myList[i];
}

두 메서드 모두 C# 일반 List<int>에 있는 모든 정수의 총합을 계산합니다. 첫 번째 예제는 데이터 값을 보유하기 위해 자동으로 생성된 프로퍼티를 사용한다는 점에서 좀더 “현대적인 C#” 입니다.

겉으로는 두 개의 코드가 동일해 보이지만, 메서드 호출에 대해 코드를 분석할 경우 차이가 극명합니다.

예제1:

int Accum { get; set; }
Accum = 0;

for(int i = 0;
       i < myList.Count;    // call to List::getCount
       i++) {
    Accum       // call to set_Accum
+=      // call to get_Accum
myList[i];  // call to List::get_Value
}

여기서, 루프를 실행할 때마다 메서드 호출이 네 번 있습니다.

  • myList.CountCount 프로퍼티에 있는 대해 get 메서드를 호출합니다.
  • Accum 프로퍼티에 있는 getset 메서드를 호출해야 합니다.
  • 더하기 연산에 전달될 수 있도록 Accum의 현재 값을 가져오기 위해 get 합니다.
  • 더하기 연산 결과를 Accum에 할당하기 위해 set 합니다.
  • [] 연산자는 이 리스트의 특정 인덱스에 있는 값을 가져오기 위해 리스트의 get_Value 메서드를 호출합니다.

예제2:

int accum = 0;
int len = myList.Count;

for(int i = 0;
    i < len; 
    i++) {
    accum += myList[i]; // call to List::get_Value
}

두 번째 예제에서, get_Value 호출은 남아 있지만 그 밖의 다른 메서드는 모드 제거됐거나, 더 이상 루프를 반복할 때마다 실행되지 않습니다.

  • accum이 이제 프로퍼티가 아닌 기본형 값이기 때문에, 값을 설정하거나 가져오기 위해 메서드를 호출할 필요가 없습니다.

  • 루프가 실행되는 동안 myList.Count는 달라지지 않을 것으로 예상되기 때문에, 해당 루프의 조건문 밖으로 액세스를 이동되어 각 루프 반복이 시작될 때마다 더 이상 실행되지 않습니다.

두 가지 버전의 소요 시간은 특정 코드의 메서드 호출 오버헤드를 75% 제거하는 것의 진정한 이점을 보여줍니다. 최신 데스크톱 컴퓨터에서 100,000번 실행했을 때,

  • 예제 1은 실행에 324ms 걸립니다.
  • 예제 2는 실행에 128ms 걸립니다.

여기서 중요한 문제는 Unity가 거의 메서드를 인라이닝하지 않는다는 것입니다. IL2CPP 하에서 조차도, 많은 메서드가 현재 적절히 인라인화 하지 못합니다. 특히 프로퍼티일 경우 더욱 그렇습니다. 또한, 가상 또는 인터페이스 메서드는 절대 인라인화 될 수 없습니다.

따라서 C# 소스에서 선언된 메서드 호출은 최종 바이너리 애플리케이션에서 결국 메서드 호출을 생산하게 될 가능성이 매우 높습니다.

단순 프로퍼티

Unity는 개발자들의 편의를 위해 데이터 형식에 많은 “단순한” 상수를 제공합니다. 하지만 위의 관점에서, 이런 상수는 일반적으로 상수값을 반환하는 프로퍼티로 구현됩니다.

Vector3.zero의 프로퍼티 실제 코드는 다음과 같습니다.

get { return new Vector3(0,0,0); }

Quaternion.identity도 매우 유사합니다.

get { return new Quaternion(0,0,0,1); }

이러한 프로퍼티에 액세스하는 비용은 프로퍼티 주변의 실제 코드와 비교했을 때는 보통 작은 수준이지만, 프레임당 수천 번(또는 그 이상) 실행될 경우에는 작은 차이가 발생할 수 있습니다.

단순한 기본형의 경우, const 값을 대신 사용합니다. const 값은 컴파일할 때 인라인화 됩니다 - const 변수에 대한 참조는 이 값으로 대체됩니다.

참고: const 변수의 모든 참조는 이 값으로 대체되기 때문에 긴 스트링이나 그 밖의 다른 데이터 타입 const를 선언하는 것은 권장하지 않습니다. 이는 최종 명령 코드에서 모든 복제 데이터로 인해 최종 바이너리의 크기가 불필요하게 부풀려지게 됩니다.

const가 적절하지 않은 곳에서는 static readonly 변수를 대신 사용합니다. 일부 프로젝트의 경우 Unity의 내장된 단순 프로퍼티조차 static readonly으로 대체되어 성능이 조금 개선됩니다.

단순 메서드

단순 메서드는 더 까다롭습니다. 특히 함수를 선언할 수 있고 어느 곳에서든 재사용하는 데 매우 유용합니다. 하지만 빠르게 반복하는 내부 루프에서는 좋은 코딩 방식으로부터 벗어나특정 코드를 “수동으로 인라인화”해야 할 수도 있습니다.

일부 메서드는 즉시 제거될 수 있습니다. Quaternion.Set, Transform.Translate 또는 Vector3.Scale를 고려하십시오. 이러한 메서드는 매우 사소한 작업이기 때문에 간단한 대입문으로 대체될 수 있습니다.

더 복잡한 메서드의 경우, 성능이 좋은 코드를 유지하는 데 드는 장기적 비용에 대비해 수동 인라이닝에 대한 프로파일링 내용을 잘 비교해보십시오.

일반 최적화
에셋 로드 지표