결과가 같은 두 API 사이에서 33퍼센트가 갈린다
광역기 한 방에 적 1만 마리가 동시에 죽습니다. 그 프레임만 튑니다.
죽었다는 표시를 하는 방법이 두 가지 있습니다.
// A. 이미 붙어 있는 컴포넌트를 켠다
if (health.Value <= 0) dead.ValueRW = true;
// B. 컴포넌트를 붙인다
if (health.Value <= 0) ecb.AddComponent<DeadTag>(sortKey, entity);
결과는 똑같습니다. 둘 다 “이 엔티티는 죽었다” 를 표시하고, 뒤따르는 시스템이 그걸 보고 파괴합니다.
구조 변경이란 무엇인가
ECS 는 같은 컴포넌트 구성을 가진 엔티티를 한 청크에 모아 둡니다. 그 구성을 아키타입이라 합니다.
컴포넌트를 하나 붙이면 아키타입이 바뀝니다.
Enemy + Health 였던 것이 Enemy + Health + DeadTag 가 되니 다른 청크로 가야 합니다.
데이터를 통째로 옮기고, 원래 청크의 빈자리를 메우고, 다른 엔티티의 인덱스를 갱신합니다.
이사입니다.
이걸 구조 변경이라 부르고, 공식 문서가 제약을 하나 못 박아 뒀습니다.
You can only perform them on the main thread; not from jobs.
잡 안에서는 못 합니다. 여러 워커가 동시에 청크를 재배치하면 서로를 밟으니까요.
그래서 잡 안에서는 EntityCommandBuffer 에 예약만 하고,
잡이 끝난 뒤 메인 스레드가 하나씩 재생합니다.
반면 IEnableableComponent 는 컴포넌트를 미리 붙여 두고 켜고 끄기만 합니다.
the target entity doesn’t change its archetype, ECS doesn’t move any data
아키타입이 안 바뀌니 이사가 없습니다. 그리고 이사가 없으니 잡 안에서 해도 됩니다.
재 봤습니다
원리는 A가 유리하다고 말합니다. 얼마나 유리한지는 다른 문제입니다.
광역기로 9,600 마리를 동시에 죽이고 그 프레임의 스파이크를 쟀습니다. 각 조건 20라운드씩.
측정 조건
이 실험은 조건을 맞추는 데 재는 시간보다 오래 걸렸습니다.
- 수렴을 기다립니다. 스폰 반경 100에 속도 3이면 적이 다 모이는 데 최대 47초 걸립니다. 95퍼센트가 사거리 안에 들어올 때까지 기다린 뒤 발사합니다. 덕분에 40라운드가 전부 9,592에서 9,651 사이(0.6퍼센트 안)로 맞았습니다. 죽은 수가 다르면 애초에 비교가 안 됩니다
- 발사 직전에
SpawnSystem을 끕니다. 안 끄면프레임 N: 1만 파괴다음에프레임 N+1: 1만 생성이 겹쳐서 사망 비용만 떼어낼 수가 없습니다 - 비포커스 프레임은 버립니다. 에디터가 백그라운드면 유니티가 루프를 스로틀해서 프레임타임이 작업 시간이 아니라 대기 시간이 됩니다
- 라벨을 손으로 안 답니다.
TypeManager.IsEnableable(GetTypeIndex<DeadTag>())로 타입이 스스로 말하게 했습니다. 브랜치를 바꿔 놓고 라벨을 안 바꾸는 사고를 원천 차단합니다
결과
| A: Enableable | B: AddComponent | |
|---|---|---|
| 죽은 수 | 9,614 | 9,624 |
| 스파이크 최대 | 31.71 ms (sd 6.96) | 42.26 ms (sd 4.03) |
| 범위 | 24.26 ~ 44.38 | 34.20 ~ 51.63 |
| +10.55 ms (+33퍼센트) · t = 5.87 |
A: 24.26 ────────── 44.38
B: 34.20 ────────── 51.63
↑ B 의 최솟값이 A 의 평균(31.71)보다 높다
B 의 20라운드 중 최고 성적이 A 의 평균보다 나쁩니다. 우연으로 설명이 안 됩니다.
무엇이 갈랐나
“비트라서 싸다” 가 절반입니다. 그리고 나머지 절반이 더 큽니다.
1만 명이 동시에 하는 것과 1명이 순서대로 하는 것의 차이입니다. 연산이 싸다는 것보다 병렬로 할 수 있다는 쪽이 큽니다.
Enableable 도 공짜는 아니다
A가 이겼다고 비용이 0인 것은 아닙니다. 문서에 이렇게 적혀 있습니다.
to prevent race conditions, jobs with write access to enableable components might cause main-thread operations to block until the job completes, even if the job doesn’t enable or disable the component on any entities.
EnabledRefRW<DeadTag> 를 쓰는 것만으로, 한 마리도 안 죽여도
메인 스레드가 막힐 수 있습니다. 데미지 적용 잡이 정확히 그 경우입니다.
정리하면 이렇습니다.
| 비용의 정체 | |
|---|---|
| A. Enableable | 동기화 대기 |
| B. AddComponent | 구조 변경 1만 회 |
둘 다 비용이 있고 종류가 다릅니다. A 가 항상 답인 것도 아닙니다. 한 프레임에 몇 개만 바뀌는 상태라면 구조 변경이 문제가 안 되고, 오히려 쿼리에서 아예 빠지는 쪽이 순회 비용을 줄입니다.
지표를 바꾸니 결론이 나왔다
이 실험은 같은 데이터로 두 번 결론이 갈렸습니다. 이 부분이 결과보다 더 남았습니다.
처음 쓴 지표는 이랬습니다.
초과분 = (발사 후 15프레임 합) - (기준선 × 15)
| 평균 | sd | 범위 | |
|---|---|---|---|
| A | 11.84 | 17.48 | -20.78 ~ 60.17 |
| B | 16.46 | 10.01 | 2.08 ~ 39.82 |
t = 1.03. 아무 말도 못 합니다. 그리고 A 에 음수가 나옵니다.
9,600마리를 죽였는데 평상시보다 빨랐다는 뜻이라 말이 안 됩니다.
원인은 지표의 모양이었습니다. 기준선을 재는 60프레임 동안 에디터 히칭이 한 번만 끼어도 기준선이 부풀고, 그만큼 초과분이 음수로 튑니다.
두 노이즈 값을 빼서 만든 지표는 노이즈가 더해집니다.
스파이크 최대 로 바꾸니 t = 5.87 이 나왔습니다.
발사 프레임 하나만 보므로 기준선을 안 뺍니다.
40라운드 전부 @frame+0 에서 최댓값이 나온 것이 이 지표가 옳다는 증거였습니다.
표본도 결론을 바꿨습니다. 같은 실험이 n=5 에서 t = 1.27 이었습니다.
n을 20으로 늘리니 뚫렸습니다. 노이즈가 크면 표본으로 이깁니다.
다만 이건 신호가 10ms 라서 가능했습니다. 신호가 1ms 인데 노이즈가 ±0.5ms 인 실험은 표본을 늘려도 가망이 없습니다. 무엇을 재느냐가 정해지면 이길 수 있는 싸움인지도 대체로 정해집니다.