작성: 2026-08-08 · 목적: 외부 라이브러리 최소화 · 근거: 사용처 32건/16파일 전수 조사
전면 교체는 권고하지 않는다. 단, 현재 사용 중인 9개 지점 중 실제로 동시성이 필요한 곳은 2곳뿐이다.
핵심 발견은 교체 가능성이 아니라 현행 사용의 부정확성이다.
parallel_for·parallel_invoke는 코드베이스에 한 건도 없다. 병렬성은 전부 자체ThreadPool이 담당한다.- 렌더 큐 8종(
concurrent_vector)은 생산자가 없다 —PushRenderQueue계열 3함수의 호출처가 코드베이스에 존재하지 않는다. RenderScene.h의concurrent_unordered_map.h는 죽은 include다 — 실제 맵은 전부std::unordered_map.HLSLCompiler는 동시성 컨테이너를 쓰면서 오히려 레이스가 남아 있다.
따라서 작업 순서는 "PPL → STL 치환"이 아니라 죽은 것 제거 → 잘못 쓰는 것 교정 → 남은 것 판단이다.
각 지점의 push/pop 호출부를 역추적해 실제 스레드를 특정한 결과다. 초판의 "SPSC 추정"은 패턴만 보고 내린 것이라 이번에 근거를 붙여 재판정했다.
| 지점 | 근거 | 조치 |
|---|---|---|
RenderScene.h:27 concurrent_unordered_map.h |
죽은 include — 실제 맵은 전부 std::unordered_map(48·50·51·59행) |
즉시 제거 |
RenderPassData.h:17-18 concurrent_vector<*Proxy*> ×8 |
생산자 없음 — PushRenderQueue 계열 3함수의 호출처가 코드베이스에 0 (§2) |
판정 후 삭제 |
IRenderPass.h:30 concurrent_queue<ID3D11CommandList*> |
DX11 디퍼드 컨텍스트 전용. 패스당 3×10=30 인스턴스 | DX11 은퇴 시 소멸 |
전부 소수 push + 프레임당 1회 drain 형태다. 락 경합이 성립하지 않는다.
| 지점 | 생산자 | 소비자 | 빈도 |
|---|---|---|---|
ProxyCommandQueue.h:13 |
게임 — RenderSceneBridge.cpp 8곳 |
렌더 — EnhancedSceneRendererLive.cpp:2154 |
3프레임 링으로 시간 분리 |
EffectProxyController.h:13 |
게임 | 렌더 — EffectManager.cpp:58 |
위와 동일 구조 |
EffectRenderProxy.h:8 |
게임 | 렌더 (TryPop) |
프록시별, 소량 |
DataSystem.h:118 |
메인 — 드롭 핸들러 App.cpp:324 |
렌더 — ImGuiRenderer.cpp:366 |
사용자가 파일을 끌어놓을 때만 |
RenderScene.h:160-161 |
프록시 소멸 지점 ×2 | 렌더 — OnProxyDestroy() |
파괴 시점만 |
| 지점 | 근거 | 비고 |
|---|---|---|
Core.ThreadPool.h:18 |
진짜 MPMC — 워커 N개 동시 pop | 부하는 낮다 (§5-정정) |
RenderPassData.h:19-20 concurrent_vector<HashedGuid> ×3 |
스레드풀 잡 6개가 같은 벡터에 동시 push (Scene.cpp:552~620) |
대안 있음 (§4) |
HLSLCompiler.h:81 |
BS::thread_pool이 셰이더 로드를 병렬 실행 (§3) |
부팅 시 1회뿐 |
m_deferredQueue·m_forwardQueue·m_terrainQueue·m_foliageQueue·m_shadowRenderQueue·
m_UIRenderQueue·m_decalQueue·m_spriteRenderQueue를 채우는 경로는
PushRenderQueue / PushShadowRenderQueue / PushUIRenderQueue 세 함수뿐인데,
이 함수들의 호출처는 RenderPassData.h(선언)와 RenderPassData.cpp(정의) 밖에 없다.
소비 측은 살아 있다 — DX12가 읽는다:
EnhancedSceneRenderer.cpp:2341-2342—copyQueue(renderData->m_deferredQueue, draws)EnhancedSceneRenderer.cpp:2422—m_UIRenderQueue를 flat에 insertEnhancedGizmoSceneTest.cpp:124-125— 동일
즉 DX12 라이브 경로는 항상 빈 큐를 읽고 있을 가능성이 높다. DX11 은퇴 과정에서 채우는 쪽이 먼저 사라지고 읽는 쪽만 남은 형태다. 이건 컨테이너 교체 문제가 아니라 파이프라인 결손이므로 DX12 라이브 작업에서 먼저 판정해야 한다.
참고: 소비 측은 어차피
std::vector로 즉시 복사한다(copyQueue). 소비 경로에는 동시성 컨테이너가 필요 없다.EnhancedUIPass.h:118-119주석도 같은 인식을 적어 두었다 — 포인터+개수로 받아 컨테이너 타입을 헤더가 모르게 했다.
RenderPassData.cpp:286 — SortShadowRenderQueue()가 m_deferredQueue.empty()를 검사하고
m_shadowRenderQueue를 정렬한다. 검사 대상과 정렬 대상이 다르다.
// HLSLCompiler.h:81-82
static concurrency::concurrent_unordered_map<std::string, ComPtr<ID3DBlob>> m_shaderCache;
//static std::mutex m_compileMutex; // ← 주석 처리됨세 가지가 겹쳐 있다.
operator[]대입이 원자적이지 않다.HLSLCompiler.cpp:76의m_shaderCache[key] = shaderBlob은 슬롯 삽입만 스레드 안전하고,ComPtr대입(참조 카운트 조작)은 보호되지 않는다. 같은 셰이더를 두 스레드가 동시에 컴파일하면 그대로 레이스다.find→operator[]사이에 창이 있다 (HLSLCompiler.cpp:16-18).clear()는 동시 안전하지 않다 (CleanUpCache,HLSLCompiler.h:74). PPL 문서상clear/erase는 단독 접근을 전제한다.
→ std::unordered_map + std::mutex로 되돌리는 것이 기능적으로 더 안전하다.
주석 처리된 뮤텍스를 되살리는 방향이다.
이 캐시를 동시에 만지는 주체가 확인됐다. ShaderSystem.cpp:44의 BS::thread_pool pool이
AddShaderFromPath를 병렬 제출하고(69·78·86·96행), 그 안에서 LoadFormFile이 캐시를 읽고 쓴다.
단, 이 병렬성은 LoadShaders() — 엔진 부팅 시 1회뿐이다.
같은 흐름의 도착지인 AddShader는 이미 std::mutex 6개로 셰이더 타입별 맵을 보호한다
(ShaderSystem.cpp:524~574). 즉 같은 병렬 구간에서 맵 6개는 뮤텍스로 지키고 캐시 맵 하나만
PPL로 지키는 상태다. 뮤텍스로 통일하면 패턴이 일관되고 PPL 의존이 하나 준다.
BS::thread_pool(bshoshany/thread-pool)이 vcpkg로 이미 들어와 있다(vcpkg-installed-ports.json:119).
자체 ThreadPool이 있는데도 ShaderSystem.cpp 한 곳에서 지역 변수로 병행 사용 중이다.
외부 라이브러리 최소화 관점에서는 PPL보다 이쪽이 먼저 걸린다 — 사용처가 한 곳뿐이라
자체 풀로 치환하는 비용이 작다.
같은 파일의 m_shaderReloadThreadPool(ShaderSystem.h:71)은 Initialize에서 생성만 되고
Enqueue 호출이 0건이다. Finalize가 delete하지 않아 누수이기도 하다.
LoadFormFile은 확장자로 두 갈래로 갈린다. 성격이 완전히 다르다.
| 경로 | 호출 | 성격 | 실측 규모 | 병렬화 이득 |
|---|---|---|---|---|
.cso |
D3DReadFileToBlob(HLSLCompiler.cpp:111) |
디스크 읽기 + 메모리 복사 | 294개 / 9.1 MB | 작다 |
.hlsl |
D3DCompileFromFile(:57) |
CPU 바운드 컴파일 | 147개 / 481 KB | 크다 |
분기는 ShaderSystem.cpp:59~99가 정한다 — cso가 존재하고 hlsl보다 최신이면 cso를 읽고,
아니면 컴파일한다. 따라서 평시 실행은 거의 전부 .cso 경로이고, 셰이더를 수정한 직후
첫 실행에서만 컴파일이 돈다.
→ 의문은 절반 타당하다. 평시에는 병렬화가 거의 값을 하지 않고(NVMe에서 9 MB 순차 읽기), 셰이더 수정 후 첫 실행에서는 147개 컴파일이 병렬로 갈라져 이득이 크다. 벤치마크를 평시 상태에서 돌렸다면 측정한 것은 컴파일 성능이 아니라 I/O 편차일 수 있다.
m_shaderCache는 넣기만 하고 히트가 발생하지 않는다.
LoadShaders()— 디렉터리를 순회하며 각 파일을 1회씩만 로드한다. 중복 경로가 없다.ReloadShaders()—HLSLCompiler::CleanUpCache()를 먼저 부르고(ShaderSystem.cpp:129) 시작하므로 캐시는 항상 비어 있다.- 개별 핫 리로드 경로는 없다 —
ReloadShaderFromPath는ReloadShaders내부(162·166·171행) 에서만 호출된다.
즉 캐시는 블롭을 영구 보관하는 역할만 한다. AddShader가 셰이더 객체를 만든 뒤 원본 블롭은
쓰이지 않으므로, 상주 메모리만 늘린다.
→ PPL 컨테이너 교체를 논하기 전에 이 캐시가 필요한지부터 물어야 한다. 캐시를 제거하면 §3의 레이스도 함께 사라진다. 남기려면 히트가 생기는 경로(개별 파일 감시 기반 핫 리로드)를 먼저 만들어야 하는데, 그때는 캐시 무효화가 없으면 옛 블롭을 반환하므로 키에 타임스탬프를 넣는 설계가 필요하다.
DX12 렌더러가 셰이더 바이트코드를 어디서 얻는지 확인한 결과, DX11 셰이더 자산과 완전히 분리되어 있다.
- DX12 패스 24개 전부가
.cpp안의 raw string literal을D3DCompile로 런타임 컴파일한다 (EnhancedGBufferPass.cpp:202,EnhancedDeferredPass.cpp:295,EnhancedForwardPass.cpp:690,EnhancedSkyBoxPass.cpp:94,EnhancedUIPass.cpp:119등). RHI/DX12전체에서.cso읽기(D3DReadFileToBlob) 0건,HLSLCompiler::호출 0건,ShaderSystem->호출 0건.Material::GetShaderPSO()참조도 0건 —DX12PSOManager.h:16의 설명 주석 한 줄이 전부다. ("ShaderPSO/VisualShaderPSO는 이름만 PSO인 '셰이더 묶음'이다. 진짜 PSO는 DX12에서…")
받을 자리는 이미 있다. DX12GraphicsPipelineDesc(DX12PSOManager.h:28)는 vsBytecode/
psBytecode를 const void* + 길이로 받아 출처를 묻지 않는다. 다만 지금은 그 자리에
인라인 컴파일 결과만 꽂힌다.
그리고 ShaderSystem은 Shader.h를 통해 ID3D11Device::CreateVertexShader 계열로 DX11 셰이더
객체를 만든다 — 디바이스 종속이라 DX12가 재사용할 수 없는 구조다.
따라서 이 절과 §3의 리팩터링(캐시 제거·PPL 교체)은 지금 하면 낭비다. 두 갈래 중 어느 쪽이든 이 코드는 유지 대상이 아니다.
- DX11이 은퇴하면 →
HLSLCompiler·ShaderSystem·m_shaderCache가 통째로 사라진다. - DX12가 머티리얼 셰이더를 받게 되면 → 필요한 것은 바이트코드뿐이므로(디바이스 객체 생성 불필요) 현재 경로를 재사용할 수 없고 새로 만들어야 한다.
남는 것은 PDB 덮어쓰기 버그(아래)뿐이다. 그건 DX11이 살아 있는 동안 계속 물릴 수 있으므로 독립적으로 고칠 값이 있다.
이건 PPL 논의 밖이지만 같은 조사에서 드러났으므로 적어 둔다.
- 147개 hlsl / 294개 cso 자산이 DX12에서 미사용이다. DX11 은퇴 시점에 이대로면 자산이 통째로 버려진다.
- 머티리얼이 지정한 커스텀 셰이더(ShaderDSL·VisualShader)가 DX12에서 무시된다. 모든 드로우가 패스에 하드코딩된 셰이더로 그려진다.
- 셰이더 핫 리로드도 DX12에서는 의미가 없다.
- 매 실행마다 24개 패스 셰이더를 런타임 컴파일하므로 그 비용이 부팅 시간에 실린다 —
사전 컴파일(cso) 도입 후보지만, 그때 필요한 것은 지금의
HLSLCompiler가 아니라 바이트코드 로더다.
§2의 "렌더 큐에 생산자가 없다"와 같은 결의 결손이다 — DX12가 아직 자기 소스로만 그리는 단계다.
HLSLCompiler.cpp의 두 경로가 완전히 같은 파일명을 만든다.
:78 std::string csoPath = ...RelativeToPrecompiledShader() + filePath.stem() + ".cso";
:97 std::string pdbPath = ...RelativeToPrecompiledShader() + filePath.stem() + ".cso"; // ← .pdb가 아니다DEBUG 빌드에서 hlsl을 컴파일하면 방금 쓴 cso를 디버그 블롭으로 덮어쓴다. 다음 실행은 그 파일을
셰이더로 읽는다. _DEBUG 한정이므로 릴리스에서는 드러나지 않는다.
m_shaderReloadThreadPool은 죽은 멤버가 아니라 중단된 마이그레이션의 잔해다.
경위: 벤치마크에서 BS 쪽이 우세해 교체를 진행하다가 크래시가 나서 되돌렸고, 원인은 미규명
상태로 남았다(마감 압박). LoadShaders()만 BS로 넘어간 채 멈춘 것이 현재 모습이다.
먼저 배제된 후보부터. SetThreadInitCallback/SetThreadExitCallback은 Core.ThreadPool.h에
정의만 있고 호출처가 0건이다. 따라서 "자체 풀은 워커에서 COM을 초기화했는데 BS는 안 했다"는
설명은 성립하지 않는다 — 양쪽 다 워커에 COM 초기화가 없다(CoInitializeEx는 Dx11Main·
GameMain의 CB/CE 전용 스레드에만 있다).
SceneManagers->m_threadPool은 공유 풀이다. 프레임 컬링(Scene.cpp), 지형 저장
(Terrain.cpp), 모델 로드(ModelSceneBridge.cpp)가 전부 같은 인스턴스를 쓴다.
void NotifyAllAndWait() {
if (m_taskCounts.load(std::memory_order_acquire) == 0) return;
WaitForSingleObject(m_waitEvent, INFINITE);
}이 대기는 전역 카운터 기반이라 누가 넣은 작업인지 구분하지 않는다. Terrain이 부른
NotifyAllAndWait은 Scene의 컬링 잡까지 기다리고, 그 반대도 성립한다. 즉 호출자가 의도한
것보다 넓은 배리어로 동작하며, 서브시스템 간 순서를 우연히 지켜주고 있었다.
BS로 옮기면서 submit_task가 돌려주는 future로 대기를 바꾸면 의미론이 내 작업만 대기로
좁아진다. 넓은 배리어에 기대고 있던 코드가 있었다면 이 전환에서 그대로 깨진다 —
기억에 남은 "설계 차이"와 부합한다.
전환 흔적이 코드에 그대로 남아 있어 이 가설을 뒷받침한다:
// ShaderSystem.cpp:103~105
// 3) 모든 제출 작업 완료 대기 (NotifyAllAndWait 대체)
for (auto& f : futures) f.get();
// 또는 pool.wait(); // 풀에 들어간 모든 작업을 기다릴 때"NotifyAllAndWait 대체"라는 주석과, 전역 대기(pool.wait())를 대안으로 남겨둔 흔적이
곧 두 의미론 사이에서 고민한 자국이다.
검증법: NotifyAllAndWait 호출부 4곳(Scene.cpp:677, Terrain.cpp:479,
ModelSceneBridge.cpp:34,55)에서, 대기 직전에 자신이 넣지 않은 작업이 큐에 남아 있는지 로그로
센다. 남아 있다면 그 호출부는 전역 배리어에 의존하고 있다.
자체 풀은 워커를 THREAD_PRIORITY_HIGHEST로 만든다(Core.ThreadPool.h:20). BS는 기본
우선순위다. 또 스레드 수 산정도 다르다 — 자체는
GetActiveProcessorCount(ALL_PROCESSOR_GROUPS), BS는 hardware_concurrency()(MSVC에서는
현재 프로세서 그룹만 센다). 둘 다 실행 순서와 병렬도를 바꾸므로, 원래 있던 레이스가
BS에서만 드러났을 수 있다. 이 경우 진짜 원인은 BS가 아니라 그 레이스다.
검증법: 자체 풀의 우선순위를 THREAD_PRIORITY_NORMAL로, 스레드 수를 BS와 같게 맞춘 뒤
재현되는지 본다. 재현되면 원인은 BS가 아니다.
int prev = m_taskCounts.fetch_add(1, std::memory_order_relaxed);
if (prev == 0) ResetEvent(m_waitEvent); // ← fetch_add와 원자적이지 않다
m_tasks->push(std::move(task));fetch_add와 ResetEvent 사이에 워커가 직전 작업을 끝내고 SetEvent를 부르면, 뒤늦은
ResetEvent가 그 신호를 덮어쓴다 → NotifyAllAndWait 무한 대기. 다중 생산자에서 성립한다.
§5-정정의 permit 유실과는 별개 경로다.
이건 자체 풀 쪽 결함이므로 BS 교체의 원인은 아니지만, 재현 실험 중 잡음이 되므로 먼저 막아두는 편이 낫다.
- 가설 2를 먼저 친다 — 자체 풀의 우선순위·스레드 수만 BS와 맞춰 재현 시도. 가장 싸고, 재현되면 BS는 무죄이며 진짜 레이스를 잡는 문제로 바뀐다.
- 재현되지 않으면 가설 1 — 대기 의미론. 위 로그 계측으로 전역 배리어 의존을 찾는다.
- 그 전에 가설 3을 막아 잡음을 줄인다.
Scene.cpp:538-620에서 카메라마다 스레드풀 잡을 6개 띄우고, 각 잡이 같은
RenderPassData의 m_findProxyVec[index] / m_findShadowProxyVec[index]에 push_back한다.
static·skinned·terrain·foliage·decal·sprite 잡이 동시에 같은 벡터에 쓴다.
여기서 concurrent_vector는 정당하다. 다만 두 가지 대안이 더 낫다.
- 대안 A — 잡별 스레드 로컬 수집 후 병합: 각 잡이 자기
std::vector에 모으고 마지막에 한 번 병합. 동시성 자체를 없앤다. 병합 비용은 프록시 수백 개 수준에서 무시할 만하다. - 대안 B — 잡 종류별로 벡터 분리: 6개 잡이 각자의 벡터를 가지면 경합이 사라진다. 소비 측은 6개를 순회하면 된다.
둘 다 std::vector의 연속 메모리를 회복한다. concurrent_vector는 세그먼트 기반이라
순회·정렬에서 캐시 지역성이 나쁘다 — 실제로 RenderPassData.cpp:251,260,288,310에서
std::ranges::sort를 걸고 있다.
종료 크래시 스택(Tools/regression/SHUTDOWN-CRASH-NOTES.md:53-56)은
concurrent_vector::clear와 concurrent_queue::push가 파괴된 객체를 만진 흔적이다.
이건 수명 관리 문제이지 컨테이너 선택 문제가 아니다. STL로 바꿔도 동일하게 터진다.
교체를 크래시 해결책으로 기대해서는 안 된다.
얻는 것
- MSVC 전용 의존 제거 — PPL은 표준이 아니고 MSVC에만 있다. 이식성 확보.
concurrent_vector→std::vector: 연속 메모리, 캐시 지역성, 정렬 성능.- 락이 코드에 드러난다. 지금은 "동시성 컨테이너를 썼으니 안전하다"는 착시가 있고, §3이 그 착시의 실례다.
using namespace concurrency;가 헤더에 퍼져 있는 문제(IRenderPass.h:29,RenderPassData.h:8,RenderScene.h:29,ProxyCommandQueue.h:7)도 함께 해소된다.
잃는 것
ThreadPool의 MPMC 큐는 직접 구현해야 한다 —std::deque+mutex+condition_variable.try_pop같은 비블로킹 API를 손으로 만들어야 한다.
초판에서 ThreadPool 큐를 "유일하게 락프리가 값을 하는 자리"로 적었으나, 호출 빈도를 세어 보니
근거가 약하다. Scene.cpp:538~661이 카메라당 Enqueue 10건, 카메라 310개면 프레임당 30100잡 —
60fps 기준 초당 2천~6천 회다. 뮤텍스 하나로 세 자릿수 여유가 있는 영역이다. 나머지 풀(셰이더
리로드·에셋 로드·애니메이션)은 더 한산하다.
또한 현재 구조는 큐 push와 세마포어 release를 따로 센다(Core.ThreadPool.h:75-77). 워커가
acquire() 후 try_pop에 실패하면 permit이 유실되고 m_taskCounts가 줄지 않아
NotifyAllAndWait()이 무한 대기할 수 있다 — Scene.cpp:677·Terrain.cpp:479·ModelSceneBridge가
프레임 동기화 지점에서 이를 호출한다. 락프리 큐로 교체하면 이 창은 오히려 넓어진다
(생산자별 서브큐를 두는 구현은 큐가 비지 않아도 pop이 실패할 수 있다).
뮤텍스+condition_variable은 큐 상태와 대기를 한 락 아래 묶어 이 문제를 구조적으로 없앤다.
중립
- SPSC 성격의 프레임 링 큐들(#4~#8)은 애초에 경합이 거의 없다. 어느 쪽이든 성능 차이가 드러나지 않는다. 교체 판단은 성능이 아니라 일관성 기준으로 하면 된다.
교체 자체보다 앞서야 할 정리가 있다.
등급 A부터 — 교체가 아니라 삭제다.
- 죽은 include 제거 —
RenderScene.h:27. 위험 없음. - 렌더 큐 8종 판정 — 생산자 결손이 의도인지 결손인지 DX12 라이브 작업에서 확인.
결손이면 파이프라인 수정이 먼저이고, 폐기라면 멤버 8개와 함수 3개를 삭제한다.
SortShadowRenderQueue버그(§2)도 이때 함께. m_shaderReloadThreadPool삭제 — 죽은 멤버 + 누수(§3). PPL과 무관하지만 같은 파일이다.
그다음 등급 C의 오작동 교정.
HLSLCompiler 교체— 철회. DX12가 이 경로를 전혀 쓰지 않고(§3-0),ShaderSystem이 DX11 디바이스에 묶여 있어 어느 쪽으로 가든 유지 대상이 아니다. 여기서 손댈 것은 PDB 덮어쓰기 버그 하나뿐이며, 그것도 PPL과 무관한 독립 수정이다.- 컬 데이터 버퍼 — §4 대안 A 또는 B.
std::vector의 연속 메모리 회복. 현재 등급 C 중 유일하게 손댈 값이 있는 항목이다.
마지막이 등급 B — 순수 치환이라 이득이 가장 작다.
- 크로스 스레드 큐 5종 — 얇은 래퍼(
FrameQueue<T>) 하나로 일괄 치환.push/try_pop/empty만 있으면 현재 호출부가 그대로 통과한다. IRenderPass::CommandQueue— 손대지 않는다. DX11 은퇴와 함께 사라진다.ThreadPool— 큐 교체 전에 permit 유실(§5-정정)부터 막고, 프레임 시간 기준선을 잡는다. 외부 라이브러리 도입은 뮤텍스 버전이 실제로 밀릴 때 검토해도 늦지 않다.
1~5까지만 해도 PPL 의존 지점은 9곳에서 5곳으로 줄고, 그 과정에서 실제 결함 3건 (생산자 결손·캐시 레이스·죽은 멤버 누수)이 함께 정리된다. 6은 그 뒤에 해도 되고, 안 해도 기능적으로 잃는 것이 없다.
- 렌더 큐 판정은 MultiCameraRenderPlan.md / DX12 라이브 파이프라인 작업과 한 몸.
using namespace concurrency;헤더 오염은 Phase4CouplingPlan.md의 인용 경로 규칙과 같은 결.- 종료 크래시 수명 문제는 Tools/regression/SHUTDOWN-CRASH-NOTES.md — 컨테이너 교체로 해결되지 않음.