결과가 같은 두 API 사이에서 33퍼센트가 갈린다
Esc

    결과가 같은 두 API 사이에서 33퍼센트가 갈린다

    –UnityDOTS최적화프로파일링

    광역기 한 방에 적 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 인 실험은 표본을 늘려도 가망이 없습니다. 무엇을 재느냐가 정해지면 이길 수 있는 싸움인지도 대체로 정해집니다.