dxcompiler.dll을 찾지 못한 테스트
선택적 DLL 하나가 없다고 크래시하는 런타임은 조용히 자기 자신을 한 종류의 기계에만 가둬버린 것이다. 진짜 이식성은 어디서나 부팅하고 시끄럽게 성능이 저하되는 것을 의미한다 — 그리고 파트너들이 배포하는 것은 바로 그런 것이다.
토요일 아침, CI 대시보드, 여덟 개의 테스트가 빨갛다. 여덟 개 모두 같은 윈도우 에러 코드로 실패했다: 0xc0000135. 윈도우 네이티브 개발에 시간을 써본 사람이라면 누구나 그것을 즉시 알아본다. STATUS_DLL_NOT_FOUND. 프로세스가 필요한 DLL을 로드하려 했는데 그 DLL이 시스템에 없었다.
문제가 된 DLL은 dxcompiler.dll, 마이크로소프트 DirectX 셰이더 컴파일러 런타임이었다. CI 머신에는 그것이 없었다. 프로덕션 사용자 머신에도 없을 수 있다. 잘 갖춰진 개발자 워크스테이션 밖의 어디에서든, 런타임은 dxcompiler.dll이 존재한다고 조용히 가정해왔고 그것이 없을 때 하드하게 크래시했다.
토요일이 끝날 무렵, 세 가지 문제가 수정되었고 테스트 스위트는 71%에서 89%로 통과율이 올라갔다. 이 글은 그 각각에 대한 글이다.
애초에 이 의존성이 왜 하드했는가
셰이더 크로스 컴파일 경로는 마이크로소프트의 DXC 컴파일러를 사용해 HLSL 셰이더 소스를 받아 SPIR-V 또는 DXIL 바이트코드를 생성한다. 엔진이 배포하는 셰이더 대부분은 미리 컴파일되어 있다. 런타임의 몇몇 코드 경로는 실행 중에 새로운 셰이더를 컴파일할 수 있다. 개발 중 셰이더-퍼뮤테이션 핫 리로드, 에디터에서의 동적 머티리얼 편집, 그리고 몇 개의 디버그 경로가 그것이다.
원래 구현은 셰이더 크로스 소스에서 #pragma comment(lib, "dxcompiler.lib") 지시문으로 dxcompiler.lib에 링크되어 있었다. 이는 하드한 로드타임 의존성을 만들어냈다. 윈도우 로더는 프로세스가 시작될 때 로드타임 의존성을 해결한다. DLL이 없으면 프로세스는 main()에 도달조차 하지 못한다. 런타임 실행 파일은 그 DLL이 시스템에 없으면 시작할 수 없었다.
이것은 DXC SDK 전체를 설치해둔 개발자에게는 옳은 답이다. CI 러너, 사용자 머신, 또는 DXC가 없는 다른 어디에서든 이것은 틀린 답이다. 런타임은 부팅해야 한다. 핫 리로드 기능은 “사용 불가”를 보고해야 한다. 나머지 모든 것은 계속되어야 한다.
수정은 어떤 모습이었나
세 가지 변경, 각각 작고, 각각 정확했다.
셰이더 크로스 소스가 로드타임 로딩에서 런타임 DLL 로딩으로 옮겨갔다. #pragma comment(lib, ...) 지시문을 명시적인 LoadLibrary와 GetProcAddress 호출로 대체했다. DLL은 프로세스 시작 시점이 아니라 첫 사용 시점에 조회된다. LoadLibrary가 실패하면 그 코드 경로는 명확한 에러를 반환한다.
DXC가 없을 때를 위한 폴백 경로가 추가됐다. dxcompiler.dll이 없을 때, 셰이더 크로스 서브시스템은 두 경로 중 하나로 폴백한다. 더 오래된 D3DCompiler가 있으면 축소된 기능(SPIR-V 출력 없이 DXIL만)으로 그것으로 폴백한다. D3DCompiler마저 없으면 시끄러운 마젠타 색상 전용 프래그먼트 셰이더를 만들어내는 플레이스홀더 SPIR-V 블롭으로 폴백한다. 이 플레이스홀더는 셰이더 컴파일러를 전혀 쓸 수 없는 환경에서도 런타임이 끝까지 계속 작동하도록 보장한다. 그 시각적 신호는 지금 플레이스홀더 모드로 실행 중이라는 것을 명백하게 만든다.
우아한 실패 경로는 로그를 남기고 텔레메트리를 내보낸다. 모든 폴백은 어떤 서브시스템이 폴백했는지와 그 이유와 함께 WARNING 레벨로 로깅된다. 텔레메트리는 운영 가시성을 위해 그 폴백을 기록한다. DXC 없이 로컬에서 실행 중인 개발자는 콘솔에서 그 경고를 보고 실제 셰이더 컴파일이 필요하면 DXC를 설치해야 한다는 것을 안다. 모든 것이 미리 컴파일되어 있다고 가정하고 런타임을 실행하는 사용자는 경고를 전혀 보지 않는다. 미리 컴파일된 셰이더는 DXC 없이도 문제없이 작동하기 때문이다.
이것이 이 의존성에 맞는 옳은 형태다. 런타임은 이식 가능하다. 이 기능은 설치된 것에 따라 옵트인된다. 실패 양상은 관찰 가능하다.
같은 감사에서 드러난 두 개의 인접한 버그
셰이더 로딩 코드를 열어놓은 채로 작업하던 중, 다른 두 개의 테스트가 인접한 방식으로 실패하고 있었고 나는 같은 패스에서 그것들을 수정했다.
오디오 버스 이펙트 ABI 불일치. raku_audio_bus_add_effect와 raku_audio_bus_remove_effect C API 함수는 (handle, struct*) 시그니처로 작성되어 있었지만, 테스트 스위트는 오디오 API의 나머지 부분이 쓰는 방식대로 (handle, handle)로 그것들을 호출하고 있었다. 테스트는 세그폴트를 일으키고 있었다. 구조체 포인터 역참조가 두 번째 핸들의 비트가 닮아 보이는 주소에 우연히 존재하던 무언가를 읽고 있었기 때문이다. ABI 불일치였다.
해결책은 C API를 오디오 모듈의 나머지 부분과 맞추는 것이었다: (handle, handle). 오디오 버스에서 이펙트 체인은 런타임이 추적하는 일급 객체이기 때문에 이것이 그 API에 맞는 옳은 형태다. 구조체 포인터 버전은 나머지 리팩터에서 살아남지 못한 이전 API 설계의 잔재였다. 테스트 스위트가 옳았다. 구현이 낡아 있었다.
메모리 누수 테스트 스텁이 실제 구현을 덮어쓰고 있었다. 에셋 스트리밍 서브시스템에는 스트리밍된 에셋의 참조 카운팅을 검증하는 메모리 누수 테스트가 있다. 테스트는 AssetStreamingManager의 스텁 구현이 테스트 픽스처에 남아 있어 런타임 DLL의 실제 구현을 덮어쓰고 있었기 때문에 실패하고 있었다. 테스트는 실제 코드가 아니라 스텁을 검증하고 있었다. 스텁에는 메모리 누수가 있었다. 실제 구현에는 없었다. 테스트는 누수가 있다는 점에서는 옳았다. 그것이 누구의 누수인지에 대해서는 틀렸다.
해결책은 테스트 픽스처에서 스텁을 제거하고, 테스트가 그것에 깔끔하게 링크될 수 있도록 실제 구현에 RAKU_STREAMING_API 익스포트를 추가하는 것이었다. 올바르게 링크되고 나자 테스트는 실제 코드를 상대로 통과했다.
세 개의 수정이 함께 풀어낸 것
dxcompiler 수정으로 여덟 개의 테스트가 초록빛이 되었다. 오디오 버스 수정으로 두 개. 메모리 누수 수정으로 세 개. 통과하는 테스트의 총합은 39/55에서 49/55로 올라갔다. 89%라는 숫자는 축하할 만한 옳은 이정표지만, 더 깊은 이정표는 이제 런타임이 DXC 없이도 작동한다는 것이다. 이는 내가 아직 예상하지 못한 환경에서도 작동할 것이라는 뜻이다.
배운 것
세 가지다.
하드한 로드타임 의존성은 이식성 결함이다. 런타임이 보편적으로 존재하지 않는 DLL이나 공유 객체에 로드타임 의존성을 가지는 곳이라면 어디든, 그 런타임은 스스로를 그 DLL을 포함한 환경으로 제한한 것이다. 그 제한을 의도적으로 두는 것은 괜찮다. 실수로 두는 것은 나쁘다. “우리 런타임이 로드타임에 무엇을 요구하는가”에 대한 감사는 실행할 가치가 있다.
0xc0000135는 가장 흔한 윈도우 DLL 에러 코드이며, 그 메시지는 아무것도 드러내지 않는다. 그 에러는 DLL이 없다는 것을 알려준다. 어느 것인지는 알려주지 않는다. 어느 것인지 알아내기 위한 진단 도구들(Process Monitor, Dependencies.exe, 새로운 윈도우 ETW 추적)은 모두 작동하지만, 그것들이 존재한다는 것을 알고 실패가 일어나기 전에 미리 설정해둬야 한다. 이제 런타임은 우아한 폴백이 발동될 때 누락된 DLL의 이름을 명시하는 명확한 에러 메시지를 내보낸다. 미래의 나는 고마워할 것이다.
테스트와 구현 사이의 ABI 불일치는 대체로 테스트 쪽이 옳다. 테스트는 코드베이스의 나머지 부분이 노출하는 API를 상대로 작성되었다. 테스트가 구현과 어긋났을 때, 테스트가 옳았다. 이것은 보통의 본능인 “테스트를 고쳐라”와는 반대다. 옳은 본능은 더 넓은 API 표면을 보고 어느 쪽이 이상치인지 묻는 것이다.
파트너와 빌더가 여기서 가져가야 할 것
파트너십을 위해 엔진을 평가하고 있고, 개발자의 머신과 프로덕션 환경이 다른 환경에 배포하고 있다면(거의 모든 배포가 그렇다), 그 팀에게 그들의 하드한 로드타임 의존성에 대해 물어보라. 옳은 답은 짧은 목록이고, 그 모두가 정당화된 것이다. 틀린 답은 긴 목록이고, 그 절반은 팀이 잊어버린 것이다.
당신이 윈도우 네이티브 개발자이고 보편적으로 존재하지 않는 DLL에 대해 아직 LoadLibrary와 GetProcAddress를 쓰고 있지 않다면, 이것이 부드러운 신호다. 패턴은 작다. 이식성 이득은 크다.
당신이 코딩 에이전트가 윈도우 코드를 작성하는 AI 랩이라면, 에이전트가 작성한 코드에서 주시해야 할 패턴은 런타임 로드가 되어야 할 #pragma comment(lib, ...) 지시문과 코드베이스의 나머지 부분에서 벗어난 ABI 시그니처다. 둘 다 정적 분석으로 표시된다. 둘 다 에이전트의 리뷰 체크리스트에 있어야 한다.
주말 마감 정리. 런타임은 DXC가 없는 머신에서도 깔끔하게 부팅한다. 이틀 동안 열한 개의 테스트가 초록빛이 되었다. 다음 감사는 달력에 올라 있다.
다시 빌드하러 간다.
당신의 하드웨어가 있는 곳에서 실행되는 런타임
RakuAI는 스마트글래스와 지저분한 실제 세계를 위해 만들어진 공간 런타임이다 — 이식 가능하고, 의존성이 누락되어도 우아하며, 폴백이 일어날 때 관찰 가능하다. 어디에나 배포하려면 무엇이 필요한지 확인하라.