UniText 의 병렬은 세 조건을 모두 넘긴 프레임에만 켜진다
hpLabel.Text = hp.ToString(); // 이 프레임, 워커 스레드는 놀고 있다
체력 숫자 하나가 바뀌는 프레임을 프로파일러로 보면 UniTextWorker 스레드는 조용하고 메인 스레드만 일합니다. 고장이 아닙니다.
UniText 는 이번 프레임에 바뀐 글자의 합이 500 을 넘고, 바뀐 컴포넌트가 3개 이상이고, 논리 코어가 3개 이상일 때만 파싱부터 셰이핑까지와 메시 생성을 상주 워커 스레드에 나눕니다. 보통의 UI 프레임은 이 조건에 걸리지 않습니다. 기준은 1.0.0 브랜치의 커밋 2087eff 입니다.
병렬은 조건 셋을 모두 넘겨야 켜진다
한 프레임에 바뀐 텍스트 컴포넌트가 여럿이면, 컴포넌트마다 하는 일은 서로 상관이 없습니다. 그래서 스레드 여럿에 나눠 줄 수 있습니다. UniText 는 시작할 때 전용 스레드(워커)를 논리 코어 수보다 하나 적게 만들어 두고, 일이 없을 때는 재워 둡니다.
워커로 보낼 수 있는 일은 둘뿐입니다.
| 일 | 워커로 보낼 수 있나 | 이유 |
|---|---|---|
| 첫 패스 (글자 분석과 셰이핑) | 됨 | C# 계산과 HarfBuzz 호출뿐 |
| 줄나누기와 배치 | 메인 | 유니티 레이아웃과 얽힘 |
| 글리프 굽기의 가운데 단계 | 됨 (따로 판단) | FreeType 호출뿐 |
| 메시의 정점 만들기 | 됨 | 배열에 숫자를 채울 뿐 |
| 텍스처 올리기, 메시 넘기기 | 메인 | 유니티 API 는 메인 스레드에서만 부를 수 있음 |
워커를 깨우고 끝나기를 기다리는 데도 시간이 듭니다. 일이 적으면 메인 스레드에서 그냥 하는 편이 빠릅니다. 그래서 프레임마다 조건을 보고 정합니다. WebGL 은 스레드가 없어 언제나 메인이고, UniText.UseParallel 로 끌 수도 있습니다.
totalChars = 더티 컴포넌트들의 글자 수 합;
useParallel = totalChars > 500 // 글자 수 합이 500 을 넘고
&& count >= 3 // 더티 컴포넌트가 셋 이상이고
&& ThreadCount > 1; // 워커(ProcessorCount - 1)가 둘 이상일 때
if (useParallel && UniText.UseParallel)
{
UniTextWorkerPool.Execute(...); // 워커에 나눠 준다
}
else
{
for (int i = 0; i < count; i++)
DoFirstPass(i); // 메인 스레드에서 차례로
}
| 프레임 | 글자 수 합 | 더티 | 워커 | 결과 |
|---|---|---|---|---|
| 체력과 골드 숫자 | 12 | 2 | 7 | 메인 스레드 |
| 채팅 로그 전체 갱신 | 750 | 4 | 7 | 병렬 |
| 같은 화면, 듀얼코어 기기 | 750 | 4 | 1 | 메인 스레드 |
병렬 경로는 채팅 로그 갱신, 상점 목록 전체 새로 고침, 씬에 들어가며 UI 를 한꺼번에 만드는 순간을 위한 것입니다.
500 과 3 에는 근거 주석이 없습니다. 컴포넌트 N 개에 M 자씩 넣고 매 프레임 셰이핑부터 다시 시켜 첫 패스 시간을 비교했습니다.
| N × M | 병렬 판정 | 끔 | 켬 | 켬 / 끔 |
|---|---|---|---|---|
| 3 × 100 | 거짓 | 0.181 ms | 0.182 ms | 1.00 |
| 6 × 100 | 참 | 0.326 ms | 0.146 ms | 0.45 |
| 12 × 500 | 참 | 2.933 ms | 0.502 ms | 0.17 |
이 기기에서는 문턱을 겨우 넘긴 600자에서 이미 절반 안팎이었고, 6,000자에서 약 6분의 1 이었습니다. 코어가 적은 기기에서는 워커 수가 줄어 비율이 달라집니다.
이렇게 쓴다
텍스트에 쓸 시간을 정할 때는 병렬을 끄고 잰 값으로 정합니다.
UniText.UseParallel = false; // 전부 메인 스레드에서 돌린 값을 기준으로 잡는다
조건 하나만 모자라도 전부 메인 스레드에서 돌고, 코어가 둘인 기기와 WebGL 은 조건과 상관없이 메인입니다. 코어가 많은 개발 기기의 값으로 정하면 저사양 기기에서 넘칩니다. 병렬은 조건이 맞는 순간에 얻는 덤으로 봅니다.
일은 개수로만 나누고 배리어에서 기다린다
일을 나누는 방식은 단순합니다. 바뀐 컴포넌트를 개수만 보고 워커 수로 똑같이 나눕니다. 컴포넌트 여덟 개와 워커 넷이면 둘씩입니다.
- 글자 수는 보지 않습니다. 긴 텍스트 하나와 짧은 텍스트 일곱이면, 긴 것을 받은 워커만 오래 걸립니다.
- 먼저 끝난 워커가 남의 일을 가져가지 않습니다. 가장 늦은 워커가 끝날 때까지 모두 기다립니다.
메인 스레드는 워커를 깨운 뒤 모두 끝나기를 기다립니다. 이 기다리는 자리를 배리어라고 부릅니다. 워커는 자기 몫을 끝내면 배리어에 도착했다고 알리고, 모두 도착하면 메인이 다음 단계로 갑니다.
UniText 의 메인 스레드는 10 ms 씩 끊어 기다리고, 5초가 지나면 다 도착하지 않았어도 로그 없이 빠져나옵니다.
barrier.Reset(ThreadCount);
for (int i = 0; i < ThreadCount; i++)
workReady[i].Set(); // 워커를 깨운다. 각자 자기 구간을 처리한다
elapsed = 0;
// 모든 워커가 배리어에 도착할 때까지 10 ms 씩 기다린다. 5초가 지나면 그냥 빠져나온다
while (!barrier.Wait(10) && !isShuttingDown && elapsed < 5000)
elapsed += 10;
// 워커가 낸 예외는 여기서 메인 스레드 콘솔에 찍는다
if (exceptionCount > 0)
Debug.LogException(threadExceptions[i]);
// 다음 단계로: 래스터화, 메시 적용
| 상황 | 루프를 빠져나오는 때 | 로그 | 그 뒤 |
|---|---|---|---|
| 보통의 프레임 | 첫 10 ms 안에 모두 도착 | 없음 | 정상 |
| 워커 하나가 멈춤 | 5초 뒤. 그동안 프레임이 멈춤 | 없음. 예외가 아니라 아직 도는 중이라 | 그 워커는 아직 버퍼를 쓰는 중인데 다음 단계로 감 |
| 워커 하나가 예외를 냄 | 예외를 기록하고 배리어에 도착 | Debug.LogException |
그 워커가 맡은 나머지 컴포넌트는 처리되지 않음 |
워커 하나가 끝나지 않으면 게임이 영원히 멈추는 것보다, 5초 뒤에 그냥 넘어가는 쪽을 고른 설계입니다. 대가는 그 워커가 아직 쓰고 있는 데이터를 메인이 건드릴 수 있다는 것입니다.
에디터에서 직접 만든 모디파이어가 워커의 OnGlyph 에서 한 번 6초 자게 했습니다. 그 프레임이 5,415 ms 걸렸고 콘솔에는 아무것도 찍히지 않았습니다. 잠든 워커가 맡은 컴포넌트는 글리프 0개로 빈 채 400프레임 넘게 남았습니다.
워커에서 난 예외는 모아 두었다가 배리어 뒤 메인에서 Debug.LogException 으로 찍습니다. 다만 try 가 그 워커의 몫 전체를 감싸고 있어서, 예외가 난 컴포넌트 뒤에 있던 같은 워커의 컴포넌트도 그 프레임에 처리되지 않습니다.
이렇게 쓴다
직접 만든 모디파이어의 콜백은 워커에서도 돕니다. 콜백에는 락, 기다림, 파일 읽고 쓰기를 넣지 않고 계산만 합니다.
- 멈추면 메인이 5초 멈춘 뒤 로그 없이 넘어가고 텍스트가 빈 채 남습니다.
- 예외를 던지면 그 워커가 맡은 나머지 컴포넌트까지 처리되지 않습니다.
두 경우 모두 증상이 “텍스트가 안 나온다” 라서 원인을 메시 쪽에서 찾기 쉽습니다. 새 모디파이어는 병렬 조건을 일부러 넘기는 화면(컴포넌트 4개에 200자씩)에서 한 번 돌려 봅니다.
할당은 배열 풀로 없애고 대가로 상주한다
텍스트를 처리할 때마다 배열을 새로 만들면 GC 가 일을 합니다. UniText 는 배열을 빌려 쓰고 돌려주는 풀(UniTextArrayPool<T>)로 이것을 없앱니다. 크기는 32 부터 65,536 까지 2의 거듭제곱 열두 가지이고, 필요한 크기 이상인 것 가운데 가장 작은 배열을 빌려줍니다. 빌린 배열에는 앞사람이 쓰던 값이 그대로 남아 있습니다.
스레드가 여럿이면 풀에서 빌리고 돌려줄 때마다 서로 부딪힙니다. 그래서 스레드마다 크기별로 자기만 쓰는 자리 하나를 따로 두고, 거기 없을 때만 함께 쓰는 줄로 갑니다.
대가는 스레드마다 붙잡고 놓지 않는 메모리입니다. 글자마다 어느 폰트인지 적어 두는 캐시는 이것을 쓴 스레드마다 256 KB 이고(컴포넌트 넷을 병렬로 돌리자 메인과 워커 넷, 스레드 다섯에 생겼습니다), HarfBuzz 의 buffer 는 텍스트를 비워도 스레드에 남았습니다.
매 프레임 바뀌는 숫자는 SetText(char[], start, length) 로 넣으면 ReadOnlyMemory<char> 로 감싸기만 해서 할당이 없습니다. 함정은 읽는 쪽입니다.
한 프레임에 SetText(buffer, 0, 4) 를 1,000번 부르면 한 번에 0 B 였고, 매번 Text 를 읽게 하면 IL2CPP 34 B, Mono 30 B 가 더해졌습니다. 4글자 string 하나입니다.
이렇게 쓴다
SetText 로 넣은 텍스트는 Text 로 읽지 않습니다. Text 를 읽으면 마지막 값이 버퍼에서 왔을 때 그 자리에서 string 을 새로 만듭니다. 할당 없이 넣도록 바꾼 코드가 비교 한 줄, 로그 한 줄 때문에 매 프레임 할당을 그대로 냅니다.
// 틀림: 읽을 때마다 string 이 생긴다
if (uniText.Text != lastShown) { ... }
// 맞음: 텍스트가 아니라 원래 값을 견준다
if (seconds != shownSeconds) { ... }
바깥에서 받은 텍스트는 받는 순간 줄바꿈을 \n 으로 맞춥니다. \r 이 든 문자열은 넣을 때마다 줄바꿈을 고친 새 문자열이 만들어집니다.
정리
UniText 의 병렬은 세 조건을 모두 넘긴 프레임에만 켜집니다.
- 글자 합 500 초과, 컴포넌트 3개 이상, 논리 코어 3개 이상이어야
DoFirstPass와 메시 생성이 워커로 가고, 보통의 UI 프레임은 직렬입니다. - 일은 컴포넌트 개수로만 나누고, 메인은 워커를 최대 5초 기다린 뒤 로그 없이 넘어갑니다. 워커에서 예외가 나면 그 워커의 몫이 통째로 멈춥니다.
- 할당은 배열 풀과
SetText(char[])로 없애지만, 스레드마다 캐시가 상주하고Textgetter 가 무할당을 되돌립니다.
그래서 텍스트에 쓸 시간은 병렬을 끄고 잰 값으로 정하고, 워커에서 도는 콜백에는 계산만 넣고, SetText 로 넣은 텍스트는 Text 로 읽지 않습니다.