GC.Alloc의 핵심은 힙에 덜 쌓기이다. 근데 힙에 덜 쌓으면 모든 문제가 해결되는건 아니다. 힙에 덜 쌓음으로써 만들어지는 다양한 트레이드오프를 글에서 논해보자.
2025.10.16 - [컴퓨터 이론/Unity] - Deepdiving Garbage Collection : GC #1
Deepdiving Garbage Collection : GC #1
스크램블 또는 Python으로 프로그래밍을 시작한다음, 게임을 개발해보겠다는 마음에 학습 곡선이 그나마 나은 Unity를 시작하면 얼마 지나지 않아 GC의 중요함을 볼 수 있다. GC 관리는 중요한게 사
hyeonistic.tistory.com
구조체 VS 클래스 : 복사 비용 대폭발
대폭발이라는 워딩을 쓸 정도냐고? 그렇다..
구조체는 값 복사로 동작하는데, 크기가 커지면 성능이 급하게 저하된다. 참조(8바이트 크기의 포인터)를 전달하는 것보다 구조체 전달이 빠른 경우는 32 바이트(4배) 이하이다. 33 바이트부턴 복사 비용이 포인터 전달보다 높아진다.
[2025년 11월 7일 내용 수정 : 이 내용은 CPU 명령어의 데이터 전달 단위와 관련이 있다. 꼭 16/32바이트가 절대적인게 아니라 어떤 영향을 받는지에 대한 글을 따로 작성했다.]
https://hyeonistic.tistory.com/251
[CSAPP&Unity #3] 흔한 걸 좀 덜 쓰는 방법
혼자 리서치하다가 재밌는 두 가지가 있어 쓴다. 우선 두 가지 질의 응답을 한번 고민해보자.모든 경우의 GetComponent의 횟수는 가능한 적게 쓰는 것이 좋다고 알려져있다. 그 이유 중에서 GC를 고
hyeonistic.tistory.com
이 상황에서 빈번한 복사를 동반하는 경우 구조체의 오버헤드가 GC 비용을 초과하는 경우가 발생 할 수 있다.
Microsoft의 공식 해결책 제안 :
- 구조체당 16바이트 이하로 만들기 (32비트 시스템이 전무한 지금, 아마 32바이트 이하는 허락되지 않을까..)
- 논리적으로 단일 값을 표현 할 것 (Vector3, Color..)
- 구조체가 불변 할 것
- 빈번한 Boxing이 없을 것 (Boxing은 뒤에 후술한다)
또 다른 해결책 : ref, in, readonly struct 를 활용 할 것
// 구조체 통짜 복사. 32바이트를 넘는경우 참사가 발생한다.
void ProcessData(LargeStruct data) {}
// 8 바이트의 참조만 전달하기
void ProcessData(in LargeStruct data) {}
// Stack-only, heap 할당은 불가능하다.
public ref struct SpanBuffer<T> {
private Span<T> buffer;
}
readonly struct 의 예제 :
// 안돼!! : readonly field의 메서드 호출마다 방어적 복사가 발생한다.
// readonly field는 "변경 불가능" 해야하는데, 하지만 Normalize()는 내부 값을 바꿀 수 있음
// → 컴파일러가 복사본에서 실행해 원본 보호를 함으로써 얕은 복사가 발생해버린다.
readonly Vector3 pos;
pos.Normalize();
// 좋아!! : readonly struct는 컴파일러가 최적화한다.
public readonly struct OptimizedVector3 {
public readonly float x, y, z;
}
Object Pooling : 복잡도와 메모리 오버헤드에 대해
https://www.jacksondunstan.com/articles/3829
8년 전 글인데, Object Pooling의 문제를 8가지나 다루고 있다.
- Explicit Free 부담 : 반환을 잊어버리면, 메모리 누수가 일어난다. (내가 수동으로 해줄수가 없는데?)
- 상태 초기화 누락 : Release() 에서 필드 초기화를 빼먹는 경우 버그가 발생 할 수 있다.
- 다중 참조 추적 불가 : 참조 카운팅을 수동으로 관리해야한다..
- 컬렉션 Pooling 어려움 : List<T> 의 내부 array를 인터셉트 할 수 없다.
- Pool 자체의 오버헤드 : Stack<T> 관리 + Get() 과 Release() 가상 함수 호출로 인한 오버헤드가 발생한다.
- 쓰레드 안전성 결여 : 또 결여를 막아보겠다고 lock 을 추가하면 더 느려진다.
- Default constructor 강제 : 유효하지 않은 초기 상태를 허용하고 있다.
- API awkwardness : 모든 함수에 Pool 참조 전달이 필요하다.
제일 와닿는 것은 Pool 자체가 힙에 할당됨으로써, 최대 갯수만큼 미리 생성함에따라 미리 점유하는 메모리 사용이 발생한다. 그니까 진짜 자주 사용하는것만 Pooling 하자..
사실 이중 일부는 현대 유니티에서 일부 해결이 가능하다 : 해결책을 나란히 적어보면,
- Unity 2021+ 에서는 Object Pooling API 사용이 가능하다.
- 자동으로 초기화, 해제 콜백이 있다.
- Max size 제한을 통해 메모리 제어가 가능하다.
- Collection checks로 중복 반환을 감지 할 수 있다.
- 가벼운 객체는 풀링하지말자.
- 생성/파괴하는데 Pool 비용보다 낮다면, ObjPool 사용은 역효과를 낸다. 소잡는 칼? 팡숀? 쓰지마세요. Profiler를 통해 GC 스파이크를 측정하고 적용하자.
- Pool 통계를 추적해서 해당 내용에 적합한 조치를 수행한다.
public class TrackedPool<T> {
public int Created, Borrowed, Returned, Outstanding;
public void DebugCheck() {
if (Returned > Borrowed)
Debug.LogError("Double Return Detected!!!!!");
}
}
Zero Allocation Code : 가독성 ZERO
구조체 사용, Native Allocation 등보다 훨씬 빠른 코드가 있는데, 진짜 코드가 별 꼴이다 :
// Before: 가독성 좋음, GC 많음
string message = $"Score: {score}, Time: {time:F2}";
scoreText.text = message;
// After: GC 없음, 가독성 파괴
StringBuilder sb = m_CachedStringBuilder;
sb.Clear();
sb.Append("Score: ");
sb.Append(score);
sb.Append(", Time: ");
sb.Append(time.ToString("F2"));
scoreText.text = sb.ToString(); // 이것도 할당 발생
해결책
- 80/20원칙
- Profiler로 GC 스파이크를 일으키는 곳을 찾아낸다. 해당 부분만이라도 가독성이 파괴되더라도 효과를 얻을 수 있다면 수정을 가해야한다.
- 냅다 최적화는 그리 좋지 않아요
- ObjPool의 원칙과 같은 이야기지만, 트레이드오프를 고려해야한다. 구체적으로 어디서 문제가 생기는지를 알고 접근해야한다. 최적화 전후로 Profiler 데이터 비교는 필수이다. 근데 나도 그게 쉽지 않은데 몸에 베어있게끔 잘 해야겠다.
- Unity의 공식 성능 측정 API 사용
[Test, Performance]
public void MeasureAllocation() {
Measure.Method(() => {
// 여기에 테스트 할 코드를 적는 것!!
}).GC().Run();
}
Boxing : 미묘하게 렉걸려서 빡치게 하는 이유
(번역하기가 애매함) Interface casting, Enum Dictionary key, IEqualityComparer 등 예상하지 못한 곳에서 Boxing이 발생한다 :
Boxing은 value type을 reference type로 변환 할 때 힙에 할당하는 것. 결국 힙에 쌓는다.. Update()에서 썼다간 참사가 난다.
// 암묵적 박싱
struct TestStruct : ITest {}
ITest test = new TestStruct(); // 박싱 발생!
// Dictionary with Enum key
Dictionary<MyEnum, int> dict = new(); // EqualityComparer 박싱!
해결책
- Rider IDE의 Heap Allocation Viewer를 사용한다.
- 실시간으로 할당 지점을 하이라이트한다.
- Boxing, Closure Capture를 자동 감지한다.
- Custom IEqualityComparer를 만들어 쓰자. Struct enumerator를 써서 foreach를 최적화 하는 방법도 있다.
public struct EnumComparer : IEqualityComparer<MyEnum> {
public bool Equals(MyEnum x, MyEnum y) => x == y;
public int GetHashCode(MyEnum obj) => (int)obj;
}
var dict = new Dictionary<MyEnum, int>(new EnumComparer());
public struct FastEnumerator {
public bool MoveNext() { }
public T Current { get; }
}
'컴퓨터 이론 > Unity' 카테고리의 다른 글
| [CSAPP&Unity #3] 흔한 걸 좀 덜 쓰는 방법 (1) | 2025.11.07 |
|---|---|
| 유니티 기본 개꼬여서 정리 (0) | 2025.10.24 |
| Deepdiving Garbage Collection : GC #1 (0) | 2025.10.16 |