추론 성공 후, 결과 영상을 기록하고 NPU 내부 구조를 깊게 파고드느라 바쁜 시간을 보냈습니다. 현재 NPU 드라이버 프로젝트는 유저 모드 런타임에서 ioctl을 통해 명령을 내리면, 커널 드라이버가 NPU 레지스터를 조작해 추론을 시작하는 구조입니다.
하지만 매 추론마다 발생하는 유저-커널 모드 스위치(Mode Switch) 는 고성능 처리에 있어 불필요한 비용입니다. 비록 스레드 컨텍스트 스위치보다 오버헤드가 적다 해도, 실시간 추론이 반복되는 환경에서는 무시할 수 없는 병목이 됩니다. 따라서 향후 런타임이 직접 하드웨어를 제어하는 방식으로 개선할 계획입니다.
그 전초 단계로, 이번 포스팅에서는 비트스트림 분석을 통해 NPU가 어떻게 DMA와 Zero-copy로 대용량 데이터를 효율적으로 처리하는지 그 내부 메커니즘을 자세히 살펴보겠습니다.
// Device.c
{
PHYSICAL_ADDRESS maxAddr;
maxAddr.QuadPart = 0xFFFFFFFFLL;
deviceContext->DescRingBase =
deviceContext->DmaAdapter->DmaOperations->AllocateCommonBufferEx(
deviceContext->DmaAdapter,
&maxAddr,
PAGE_SIZE,
&deviceContext->DescRingLogicalAddress,
FALSE, /* CacheEnabled = FALSE → uncached */
MM_ANY_NODE_OK);
}
AllocateCommonBufferEx을 호출하여 DescRingBase의 kva를 얻고 있다.

CacheEnabled를 FALSE로 설정하지 않으면 드라이버(CPU)가 명령(Descriptor)을 작성했는데, 아직 CPU 캐시에만 머물고 실제 RAM에 안 올라간 상태인 경우 NPU가 정상적으로 동작하지 않을 수 있다.
{
PHYSICAL_ADDRESS descPhys = MmGetPhysicalAddress(deviceContext->DescRingBase);
deviceContext->DescRingDeviceVA = (UINT64)4096 * PAGE_SIZE; // 0x1000000
deviceContext->DescRingTail = 0;
apex_write_register(deviceContext->Bar2BaseAddress,
APEX_REG_PAGE_TABLE + (4096 * 8), descPhys.QuadPart | 1);
{
UINT64 rb = apex_read_register(deviceContext->Bar2BaseAddress,
APEX_REG_PAGE_TABLE + (4096 * 8));
DbgPrint("[%s] DescRing: VA=%p PA=0x%llx DeviceVA=0x%llx PTE[4096] write=0x%llx readback=0x%llx %s\n",
__FUNCTION__, deviceContext->DescRingBase, descPhys.QuadPart,
deviceContext->DescRingDeviceVA, descPhys.QuadPart | 1, rb,
(rb == (UINT64)(descPhys.QuadPart | 1)) ? "OK" : "MISMATCH");
}
}
MmGetPhysicalAddress api를 통해서 PA를 얻어왔는데 MSDN을 확인하니 아래와 같다.

찾아본 결과 원격 pc에 VT-d(Intel Virtualization Technology for Directed I/O)가 꺼져있어서 동작한 거라고 한다. 추후 테스트 예정
중요한 것은 아래 코드이다.
apex_write_register(deviceContext->Bar2BaseAddress,
APEX_REG_PAGE_TABLE + (4096 * 8), descPhys.QuadPart | 1);
PTE의 시작 offset 값에 4096 인덱스 지점에 PA를 맵핑해주었다. 4096 * 8로 표현한 이유는 PTE가 8byte이기 때문이다.
windbg에 출력되는 로그를 확인해보았다.

의문점 왜 bit0를 |1 연산을 하는지?
칩의 MMU에게 해당 엔트리는 사용할 수 있다 라고 표현하는 것이라고 한다.
물리 주소는 항상 4KB(페이지) 정렬이다. 페이지 시작 주소의 하위 12비트는 무조건 0이라고 볼 수 있다. 따라서 bit0가 1일 때, 유효한 매핑으로 상위 비트의 PA로 접근하는 것이다.
그러면 bitstream의 deviceVA는 어떻게 넘기는 것일까? 라는 궁금증이 발생한다.
UINT32 slot0 = pDc->DescRingTail % 256;
ring[slot0].address = pIn->BitstreamDeviceVA;
ring[slot0].size_in_bytes = (UINT32)pIn->BitstreamSize;
ring[slot0].reserved = 0;
pDc->DescRingTail++;
KeMemoryBarrier(); // ring write 가 chip 보다 먼저 보이도록
apex_write_register(bar2, APEX_REG_INSTR_QUEUE_TAIL, pDc->DescRingTail % 256);
위 코드처럼 링버퍼의 address 필드에 BitstreamVA를 넣어서 해당하는 tail을 레지스터에 써주면 된다.
그러면 또다시 bitstream의 device VA를 chip은 어떻게 알 수 있는가?라는 의문이 든다. 그것은 바로 PTE 맵핑이다.
가속기(chip)는 pa를 직접 가지고 있지 않고 MMU를 통해서 PTE를 참조하여 PA를 얻는다. 정확히 google coral tpu가 어떻게 동작하는지는 알 수 없지만 접근하고자 하는 위치의 va를 알고 있다면 pte를 통해 pa를 얻어서 접근한다고 이해했다.
// Queue.c IOCTL_ALLOC_IO_BUFFERS
WdfSpinLockAcquire(pDC->PageTableLock);
for (j = 0; j < pageCount; j++) {
UINT64 pagePa = basePa + ((UINT64)j << PAGE_SHIFT);
apex_write_register(
pDC->Bar2BaseAddress,
APEX_REG_PAGE_TABLE + ((startPte + j) * 8),
pagePa | 0x1ULL
);
}
WdfSpinLockRelease(pDC->PageTableLock);
pageCount만큼 PTE에 맵핑해주는 코드이다.
npu driver를 개발하면서 가장 낯설게 느껴졌던 부분은 위처럼 hardware가 mmu를 통해 pte를 참조해 실제 접근할 pa를 얻는 것이다. hardware가 직접 pa를 들고 있으면 되는 거 아닌가?라는 생각이 들 수 있다. 3가지 이유로 pte 맵핑을 사용한다.
메모리 파편화
흩어진 물리 메모리를 하나처럼 보이기 위해서이다. 윈도우에서는 10MB의 메모리를 할당하면, 실제 RAM에서는 4kb 단위로 여기저기 흩어져서 할당될 확률이 높다. 따라서 PTE가 있다면 가상 주소 하나만 던져주게 되면 조각난 PA들을 연결해서 읽을 수 있다.
하드웨어 보호 격리
NPU는 엄처난 속도로 메로리를 읽고 쓴다. 만약 코드에 버그가 있어서 하드웨어가 잘못된 주소에 접근한다면 시스템 전체에 문제가 발생할 것이다. 따라서 PTE에 등록해 준 영역 안에서만 놀 수 있도록 한다.
유연한 메모리 관리 (Zero-copy)
데이터를 옮기지 않고, PTE의 물리 주소 값만 바꿔주면 하드웨어 입장에서는 데이터가 이동한 것처럼 보인다.
알아야 할 것이 많지만 재밌는 것 같다.