.NET 7에 도입된 GC의 메모리 해제에 대한 segment와 region의 차이점
이번엔 아래의 글에 대한 요약/정리입니다. ^^
How segments and regions differ in decommitting memory in the .NET 7 GC
; https://maoni0.medium.com/how-segments-and-regions-differ-in-decommitting-memory-in-the-net-7-gc-68c58465ab5a
기존의 Segment 단위 대신, 새롭게 Region이 도입되고 있다는 것을 지난 글에서 설명했는데요,
닷넷 GC에 새롭게 구현되는 DPAD(Dynamic Promotion And Demotion for GC)
; https://www.sysnet.pe.kr/2/0/12653
Region은 현재 macOS를 제외한 64비트 환경에서 사용할 수 있다고 하며, 여기서 설명하는 내용은 .NET 7.0의 개발이 진행되면서 바뀔 수도 있다는 점을 유의하면 되겠습니다.
제목에서 의미하는 바에 따라, 이번 글에서는 메모리 해제(decommitting memory)와 관련해 Region의 동작 방식에 대해 설명할 것이며, 논의를 좁히기 위해 Server GC만을 대상으로 합니다.
GC는 단순히 필요한 만큼의 메모리를 commit하는데, 페이지(보통 4KB) 하나씩 commit하는 것은 부하가 크기 때문에 일반적으로는 16개의 페이지를 한 번에 commit합니다. (만약 16개 페이지 크기를 넘어가는 개체를 요구하는 경우에는, 메모리 주소가 연속되어야 하므로 더 많은 페이지를 commit합니다.) 이러한 동작은 .NET 6.0의 GC나 .NET 7.0의 GC에서도 바뀌지 않았습니다.
반면 decommit은 양상이 아주 다릅니다. 필요 없는 페이지긴 해도 금방 다시 사용해야 한다면 굳이 decommit했다가 다시 commit하게 되는 오버헤드를 감수하고 싶지는 않을 것입니다. 하지만 그에 반해, commit 상태의 사용하지 않는 공간이 너무 커지는 것 또한 원치 않을 것입니다.
또한, Free 공간이라고 해도 segment/region에 살아남은 첫 번째 개체와 마지막 개체 사이의 메모리를 해제할 수는 없습니다. 즉, 해당 공간에서 마지막으로 유효한 개체 이후의 메모리만 해제가 가능하다는 특징이 있습니다.
(흔히들 "GC는 절대 메모리를 decommit하지 않는다" 라거나 혹은 "메모리 부족 상황이 발생하지 않으면 decommit 하지 않는다"라고 잘못 알고 있는데, 현재 구현한 GC가 그렇게 간단한 정책으로 구성된 것은 아니라고 합니다.)
우선, 기존 segment 방식에서의 decommit 정책을 보겠습니다.
segment의 경우 새로운 방식의 region보다는 용량 면에서 더 큽니다. Server GC 환경에서 SOH 세그먼트의 크기는 8개 이상의 힙을 운영하는 상황이라면 1GB가 됩니다. (LOH와 POH를 모두 포함하는) UOH(User Old Heap) segment는 그보다는 작은 256MB로 시작하지만 필요에 따라 더 커질 수는 있습니다.
대개는 Heap 하나 당 segment 하나를 갖게 됩니다. 만약 48개의 heap이 생성된 응용 프로그램이라면, SOH 16개, UOH 32개(LOH 16개 + POH 16개)로 구성되고, 그 각각에 SOH는 1GB, UOH는 256MB의 segment를 가지므로 16GB + 16 * 256MB, 따라서 총 20GB의 메모리가 점유됩니다.
자, 그럼 여기서 ephemeral segment의 경우에 어떤 decommit 정책을 가지는지 먼저 설명하자면,
Blocking GC 수행의 마지막 단계에서, ephemeral segment에 마지막으로 살아 있는 객체 이후의 메모리에 대해 "full gen0을 위한 공간, 부분적으로 gen1을 위한 공간"만을 남겨 두고 나머지는 (있다면) decommit합니다. 이 상황을 아래의 그림에서 보여줍니다.
좀 더 첨언하면, 위의 동작은 .NET 5부터 적용되는 Server GC에서 다소 달라집니다. 그 버전부터는, blocking GC 동안에 decommit 작업을 하지는 않고 decommit 할 위치만을 기록하는 것이 전부입니다. 그리고 실제 decommit 작업은 사용자 스레드와 동시에 수행하는 Server GC 스레드 중 하나에서 수행합니다.
이 과정에서 확실히 해야 하는 점이 있는데, commit 상태로 둘 full gen0을 위한 최소 크기가, 같은 segment에서 할당을 해나가는 사용자 스레드와 경합을 피할 정도여야 한다는 것입니다. (이런 제약은 이후 region을 도입함으로써 사라집니다.)
ephemeral segment의 decommit을 알아봤으니, 이제 gen2로 꽉찬 segment와 UOH segment의 decommit 정책을 봐야 하는데요, 사실 이 부분은 매우 단순합니다. 그저 마지막으로 살아남아 있는 개체의 이후를 decommit하는 것이 전부입니다. 당연하겠지만, 이 작업은 gen2 수준의 GC를 하지 않는 한 발생하지 않습니다. 왜냐하면, GC를 수행해야 segment 내에 차지하고 있는 개체가 줄어들 것이고 그 줄어든 공간이 decommit 대상이 되기 때문입니다. Background GC의 경우, 이 작업은 병행이 됩니다. (대신 blocking gen2 GC 작업과는 동시에 수행하지 않도록 결정했는데, 왜냐하면 메모리가 부족한 상황에서는 blocking gen2 GC가 자주 발생할 수 있으므로 그럴 때는 최대한 빨리 decommit을 처리하는 것이 낫기 때문입니다.)
참고로, segment 적용 시의 보다 자세한 commit과 decommit 설명은 아래의 youtube 동영상에서 다루고 있다고 합니다.
Diagnosing Memory Leaks
; https://www.youtube.com/watch?v=ImeiUzbdMzc&ab_channel=MaoniStephens
자, 그럼 이제 Region이 도입된 상황에서의 decommit을 다뤄보겠습니다.
Region은 SOH의 경우 기본적으로 4MB로 작은 단위로 유지됩니다. (UOH를 위해서는 32MB 이상입니다.) 그리고, 여유 Region을 갖는 Pool을 운영합니다. 게다가 Region은 특정 세대의 GC Heap에 묶이지도 않습니다. 그렇기 때문에, 가령 0세대 개체들로 가득했던 Region이 GC로 인해 전부 수집되어 빈 Region이 되면 Pool에 반납이 되고, 이후 Gen1 또는 Gen2를 위한 메모리가 필요하게 되면 그 Region이 재사용되는 것도 가능합니다.
물론, 이것은 segment를 운영했던 때와 동일한 관리 문제가 발생합니다. 즉, Pool에 너무 많은 여유 region을 보유해서도 안 되고, 반대로 너무 작게 보유해서 다시 빈번하게 commit하는 것도 원치 않을 것입니다. 그런데, Region의 경우에는 자유도가 높은 만큼 이런 관리 면에서 segment보다는 더 복잡한 면도 있습니다. (원 글에서는 이에 대한 2가지 사례를 설명하는데 여기서는 생략합니다.)
현재 구현은, 세대 별로 최소한 1개의 Region을 보유한다는 규칙이 있습니다. 예를 들어, GC 후에 Gen0 개체들이 모두 수집이 되었어도 무조건 한 개의 Region은, 설령 그 region이 빈 region이라고 해도 Gen0 목록에 유지합니다. 혹은, Gen0이 할당되었던 Region이 (pinning 등의 이유로) GC 후에 비어 있지 않게 된다면 그런 region들 모두가 Gen0 region 목록에 여전히 남을 것이고 비어 있는 region들만 free region 리스트로 반환될 것입니다.
위와 같이 세대 별로 최소 1개의 Region을 유지하는 정책은 segment를 다루던 시기로 거슬러 올라갑니다. 즉, segment를 사용했던 경우 세대 별로 항상 한 개의 segment를 가지고 있었습니다. Region으로 바뀌면서 이런 정책을 꼭 고수할 필요는 없어졌지만, 그래도 구현이 쉽다는 이유로 남게 되었습니다.
아래 2개의 표는 위에서 설명한 내용을 잘 도식화하고 있습니다.
[첫 번째 GC 수행 전]
[첫 번째 GC 수행 후]
보는 바와 같이 GC 수행 전에 gen0 목록에는 3개의 Region이 있었지만 GC 이후 gen0 개체가 모두 수집되었는데도 gen0 목록에는 1개의 region을 보유하고 있으며, 나머지 2개의 region은 free list에 반환된 상태입니다.
만약, GC 이후 너무 많은 region이 free list에 있으면 일정량의 region은 메모리 해제를 위해 free list에 제거되고 decommit list에 추가됩니다. (위의 이미지에서는 decommit list는 누락되었습니다.)
여기서 "너무 많은"의 기준은 2가지로 결정됩니다. 첫 번째 기준은, 세대에 따른 기본 확보(budget)량입니다. 가령 gen0을 위한 확보량으로 10MB라고 결정했다면, (region이 보통 최소 4MB이므로) 3개의 region이 필요하게 됩니다. 위의 이미지에서 GC 이후의 상황을 보면, gen0의 경우 1개의 region을 가지고 있으므로 free list에는 2개의 region이 있어야 하는 것입니다. 그리고 이와 같은 계산은 gen2/gen1까지 모두 동일한 원칙이 적용됩니다. 두 번째 기준은, 얼마나 사용하지 않았느냐에 있습니다. 현재는 20번의 GC를 하고서도 여전히 한 번도 사용하지 않은 free region이 있다면 그것을 해제하도록 되어 있습니다. (향후에는 20번이라는 값을 고정하지 않고 메모리 상황에 따라 동적으로 조정할 거라고 합니다.)
그리고, 지금 당장은 free list가 SOH와 UOH 별로 따로 나누어져 있지만 나중에는 UOH용 free list의 region을 필요하다면 SOH의 free list로 전환하게 만들 것이라고 합니다.
참고로, 필요하다면 Gen0과 Gen1을 위한 region의 경우 region 자체의 해제뿐만 아니라 region 내의 영역에 대한 가상 메모리 해제도 한다고 합니다. 예를 들어, 4MB로 모두 commit된 gen1 region에 500KB가 사용 중이고 확보 용량을 1MB로 잡은 상태라면 4MB - 1MB - 500KB = 2.5MB의 영역을 commit 해제합니다. segment에서의 경우와 마찬가지로, region 내에서의 decommit 자체는 병행할 수 있지만 만약 사용자 스레드가 해당 region 내에서 할당을 하는 경우에는 동기화가 필요할 수밖에 없습니다. 하지만 사실 대부분의 decommit 작업은 decommit list에 있는 region을 대상으로 진행될 것이고, 그 작업은 동기화 작업 없이 병행이 가능하므로 결국 region 내에 decommit과 할당에 관한 부하는 작을 것입니다.
그럼, 이쯤에서 region과 segment의 decommit 차이점을 정리해볼까요? region의 경우, free list에 있는 region에 대해서는 region 내의 영역에 대한 decommit은 하지 않고 있습니다. 이를 위해 세대별 확보량(budget)을 정할 때 region에 따른 반올림을 합니다. 일례로, 확보해야 할 용량이 9MB라고 했을 때 (region 하나당 4MB이므로) 총 3개의 region이 필요하게 되므로 budget 자체를 9MB가 아닌 12MB로 정해버리는 것입니다. 만약 region 반올림을 하지 않았다면, 3개의 region 중에 마지막 1개는 4MB 중 3MB를 decommit하는 식으로 처리해 9MB를 지켰을 것입니다. 이로 인해, region의 용량이 (기본 4MB가 아닌) 큰 경우라면 기존 segment 방식과 비교해 유사한 상황에서 commit 용량이 더 크게 유지될 수 있습니다.
이후, 원본 글에서는 "Peak committed"와 안정된 상태에서의 "committed" 크기에 대해 기본 segment 방식과의 차이점을 설명하는데, 결론은 대부분의 경우 차이가 크지 않을 거라고 설득하고 있습니다. 특히 "Larger workload" 상태의 응용 프로그램이라면 그 차이는 더 작을 수 있습니다. 결국, segment와 region의 여유 공간 확보량(budget) 계산은 동일한데, 차이가 나는 것은 반올림된 마지막 1개의 region 내에서 decommit을 하지 못한 분량만큼에 불과합니다. (물론, 하필 마지막 region의 용량이 1GB이고, budget 계산을 충족하는 마지막 용량이 1MB라면 기존 segment 방식과 비교해 999MB의 committed 메모리 차이가 발생합니다.)
참고로, Region의 기본 용량을 줄이는 환경 변수 옵션이 있습니다.
// region의 기본 용량을 4MB가 아닌 1MB로 변경 (숫자는 2의 배수여야 함)
set DOTNET_GCRegionSize=100000
현재는 4MB지만, .NET 7 환경 내에서 테스트를 진행 중이며 그에 따라 바뀔 수도 있다고 합니다.
그렇다면 현재 구현된 수준의 region 도입으로 혜택을 받을 수 있는 것은 어떤 유형의 응용 프로그램일까요?
이에 대해 힙에 free space가 많은 경우라고 합니다. 그런 경우, free space에 해당하는 region은 (segment 시절에는 그대로 용량을 차지하고 있었지만) 이제 region pool에 반환될 수 있고 이후 더 이상 사용되지 않는다면 아예 메모리 해제(decommitted)까지 될 수 있습니다.
예를 들어, 아래의 그림과 같은 상황을 보면,
중간중간 pinning과 같은 경우로 인해 남게 되는 free 영역이 있는데, segment를 사용한다면 그대로 메모리를 점유하게 되겠지만, region으로 바꾸면 1MB처럼 작은 free 영역은 여전히 남아도 그 외 10MB, 30MB, 40MB, 100MB의 공간들은 region으로 구성되었을 수 있고 따라서 region pool에 반환돼 이후 해제까지 진행될 수 있습니다.
region이 도입된 GC는 현재 .NET 7에서 도입될 예정이지만, .NET 6 환경에서도 환경 변수를 통해 교체하는 것이 가능합니다.
set COMPlus_GCName=clrgc.dll
가령, .NET 7.0 브랜치에서 현재 region이 도입된 clrgc 구성 요소를 직접 빌드한 후 위와 같이 COMPlus_GCName으로 지정하면 .NET 6.0 응용 프로그램에서도 region을 이용한 메모리 변화를 체험할 수 있습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]