Go의 Green Tea 가비지 컬렉터가 메모리를 할당하고 정리하는 방식을 관찰하고, C#과 비교하며, 비이동형 수집기의 단편화 한계를 살펴봅니다.
Go 1.25는 지난해 출시되며 새로운 가비지 컬렉터인 Green Tea를 도입했습니다. 그리고 몇 달 전 출시된 Go 1.26에서는 Green Tea가 기본값이 되었습니다. 링크한 글은 훌륭합니다. 여기서는 그 내용을 되짚고, 가장 큰 이점을 얻는 몇 가지 프로그램을 살펴보겠습니다. 또 이점을 얻지 못하는 프로그램, 즉 Go에 남아 있는 가비지 컬렉터의 골칫거리인 비이동형 컬렉터가 성긴 페이지를 회수하지 못하는 문제를 드러내는 프로그램도 살펴보겠습니다.
한 걸음 물러나 보면, Go는 하나 이상의 8KiB _페이지_로 이루어진 연속된 청크(Go 용어로는 span) 안에 같은 크기 클래스의 객체(객체 크기는 가장 가까운 크기 클래스로 올림됨)를 할당하여 메모리를 관리합니다. 크기별 분리 할당은 일부 malloc 구현(예: Go의 할당기가 기원한 tcmalloc)에서 흔합니다.
Go에서 이것이 일어나는 모습을 관찰한 뒤 C#과 비교해 보겠습니다. 서로 다른 세 크기(작은 것, 중간 것, 큰 것)의 객체를 무작위로 할당하겠습니다. 그런 다음 힙 주소를 확인하고, 주소 공간을 순회하면서 우리 객체 중 하나에 도달하면 출력하겠습니다. 순회하는 32바이트마다 문자 하나를 출력합니다.
먼저 Go와 C#을 설치합니다.
sudo apt update -y
sudo apt-get install -y dotnet-sdk-10.0
curl -fsSL https://go.dev/dl/go1.26.0.linux-amd64.tar.gz | sudo tar -C /usr/local -xz
export PATH=$PATH:/usr/local/go/bin
다음은 만들고자 하는 의사 코드입니다.
struct Small { a [32]byte }
struct Medium { a [64]byte }
struct Large { a [128]byte }
constructors = [Small, Medium, Large]
live = [] # stop objects from being collected
for i in range(100):
live.push(new constructors[rand() % len(constructors)])
for pass in [0, 1]:
if pass == 1:
runtime.gc() # trigger the GC
records = []
for obj in live:
records.push((runtime.addressof(obj), runtime.typeof(obj), runtime.sizeof(obj)))
records.sort(key = r -> r.address)
cell = 32
cursor = records[0].address
for (addr, typ, size) in records:
while cursor < addr: # no object of ours here
print("."); cursor += cell
head = typ.name[0]
print(upper(head) + "-" * (size/cell - 1)) # "S" / "M-" / "L---"
cursor += size
Go로 구현해 보겠습니다.
package main
import (
"bytes"
"cmp"
"fmt"
"math/rand"
"reflect"
"runtime"
"slices"
)
type (
Small struct{ _ [32]byte } // 32 bytes
Medium struct{ _ [64]byte } // 64 bytes
Large struct{ _ [128]byte } // 128 bytes
)
type object struct {
addr uintptr
size int
name byte // 'S' / 'M' / 'L'
}
func main() {
allocs := []func() any{
func() any { return new(Small) },
func() any { return new(Medium) },
func() any { return new(Large) },
}
live := make([]any, 100) // keep refs so GC can't reclaim, and so we know each type
for i := range live {
live[i] = allocs[rand.Intn(len(allocs))]()
}
for pass := 0; pass < 2; pass++ {
if pass == 1 {
runtime.GC() // Go never moves objects: pass 1 is identical to pass 0
}
objs := make([]object, len(live))
for i, o := range live {
t := reflect.TypeOf(o).Elem()
objs[i] = object{reflect.ValueOf(o).Pointer(), int(t.Size()), t.Name()[0]}
}
slices.SortFunc(objs, func(a, b object) int { return cmp.Compare(a.addr, b.addr) })
fmt.Printf("\n=== pass %d (base 0x%x) ===\n", pass, objs[0].addr)
draw(objs)
}
}
func draw(objs []object) {
const cell, width = 32, 60
last := objs[len(objs)-1]
base := objs[0].addr
grid := make([]byte, int((last.addr+uintptr(last.size)-base)/cell))
for i := range grid {
grid[i] = '.'
}
for _, o := range objs {
c0 := int((o.addr - base) / cell)
grid[c0] = o.name
for k := 1; k < o.size/cell; k++ {
grid[c0+k] = '-'
}
}
prev := -1
for off := 0; off < len(grid); off += width {
row := grid[off:min(off+width, len(grid))]
if len(bytes.Trim(row, ".")) == 0 { // row holds none of our objects
continue
}
if prev >= 0 && off != prev+width {
fmt.Println(" ...")
}
fmt.Printf("0x%09x %s\n", base+uintptr(off)*cell, row)
prev = off
}
}
heapwalk.go
실행하면 다음과 비슷한 결과가 나옵니다.
$ go run heapwalk.go
=== pass 0 (base 0xba4841580c0) ===
0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xba484158840 M-M-M-M-....................................................
...
0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
...
0xba4841add40 ..........................L---L---L---L---L---L---L---L---L-
0xba4841ae4c0 --L---L---L---L---L---L---L---L---L---L---L---L---L---L---L-
0xba4841aec40 --L---L---L---L---L---L---L---L---L---L---L---
=== pass 1 (base 0xba4841580c0) ===
0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xba484158840 M-M-M-M-....................................................
...
0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
...
0xba4841add40 ..........................L---L---L---L---L---L---L---L---L-
0xba4841ae4c0 --L---L---L---L---L---L---L---L---L---L---L---L---L---L---L-
0xba4841aec40 --L---L---L---L---L---L---L---L---L---L---L---
따라서 서로 다른 크기의 객체들 가운데 무작위로 할당했음에도, Go 런타임이 각 크기의 객체를 서로 인접하게 배치하는 것을 관찰할 수 있습니다.
또한 가비지 컬렉터를 실행한 뒤에도 아무것도 이동하지 않았습니다.
이제 C#을 살펴보겠습니다.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
var allocs = new Func<object>[] { () => new Small(), () => new Medium(), () => new Large() };
var live = new object[100]; // keep refs so GC can't reclaim, and so we know each type
var rnd = new Random();
for (int i = 0; i < live.Length; i++) live[i] = allocs[rnd.Next(allocs.Length)]();
// A reference on 64-bit .NET is a plain 8-byte pointer, so reinterpreting one
// with Unsafe.As gives the object's address. Object sizes are measured from
// the heap: allocate several, the smallest gap between consecutive addresses
// is the (aligned) object size, header included.
var size = new Dictionary<Type, int>();
foreach (var make in allocs)
{
var keep = new object[16];
var a = new nint[keep.Length];
for (int i = 0; i < keep.Length; i++) keep[i] = make();
for (int i = 0; i < keep.Length; i++) a[i] = Unsafe.As<object, nint>(ref keep[i]);
Array.Sort(a);
nint best = nint.MaxValue;
for (int i = 1; i < a.Length; i++)
if (a[i] - a[i - 1] > 0 && a[i] - a[i - 1] < best) best = a[i] - a[i - 1];
size[keep[0].GetType()] = (int)best;
}
for (int pass = 0; pass < 2; pass++)
{
if (pass == 1) GC.Collect();
// An address is only valid until the next collection, so pause the GC
// while we take them.
var addrs = new nint[live.Length];
GC.TryStartNoGCRegion(1 << 20);
for (int i = 0; i < live.Length; i++) addrs[i] = Unsafe.As<object, nint>(ref live[i]);
GC.EndNoGCRegion();
var objs = new (nint Addr, int Size, char Name)[live.Length];
for (int i = 0; i < live.Length; i++)
objs[i] = (addrs[i], size[live[i].GetType()], live[i].GetType().Name[0]);
Array.Sort(objs, (x, y) => x.Addr.CompareTo(y.Addr));
Console.WriteLine($"\n=== pass {pass} (base 0x{(long)objs[0].Addr:x}) ===");
Draw(objs);
}
static void Draw((nint Addr, int Size, char Name)[] objs)
{
const int cell = 32, width = 60; // cell = the smallest object's size
var last = objs[^1];
nint b = objs[0].Addr;
var grid = new char[(last.Addr + last.Size - b) / cell];
Array.Fill(grid, '.');
foreach (var o in objs)
{
int c0 = (int)((o.Addr - b) / cell);
grid[c0] = o.Name;
for (int k = 1; k < o.Size / cell; k++) grid[c0 + k] = '-';
}
int prev = -1;
for (int off = 0; off < grid.Length; off += width)
{
var row = new string(grid, off, Math.Min(width, grid.Length - off));
if (row.Trim('.').Length == 0) continue; // row holds none of our objects
if (prev >= 0 && off != prev + width) Console.WriteLine(" ...");
Console.WriteLine($"0x{(long)b + (long)off * cell:x9} {row}");
prev = off;
}
}
class Small { public long a, b; }
class Medium { public long a, b, c, d, e, f; }
class Large { public long a, b, c, d, e, f, g, h, i, j, k, l, m, n; }
HeapWalk.cs
빌드하고 실행합니다.
$ dotnet run HeapWalk.cs
=== pass 0 (base 0x7aea1080a1e0) ===
0x7aea1080a1e0 L---L---L---M-M-SL---L---SM-M-M-M-SL---L---L---L---M-L---SSL
0x7aea1080a960 ---L---M-M-L---M-SL---L---SM-L---M-L---L---L---M-L---L---M-L
0x7aea1080b0e0 ---SL---SSL---L---M-L---L---M-L---L---L---SM-SL---L---SL---L
0x7aea1080b860 ---L---SSSL---L---M-SL---SM-L---SL---M-L---M-L---M-L---SSL--
0x7aea1080bfe0 -L---SSSM-SM-L---M-M-L---M-L---
=== pass 1 (base 0x7aea1080a1e0) ===
0x7aea1080a1e0 L---L---L---M-M-SL---L---SM-M-M-M-SL---L---L---L---M-L---SSL
0x7aea1080a960 ---L---M-M-L---M-SL---L---SM-L---M-L---L---L---M-L---L---M-L
0x7aea1080b0e0 ---SL---SSL---L---M-L---L---M-L---L---L---SM-SL---L---SL---L
0x7aea1080b860 ---L---SSSL---L---M-SL---SM-L---SL---M-L---M-L---M-L---SSL--
0x7aea1080bfe0 -L---SSSM-SM-L---M-M-L---M-L---
그리고 즉시 같은 크기의 객체들이 함께 묶여 있지 않다는 점을 알 수 있습니다. (뒤에서 다른 워크로드에서는 C#이 메모리에서 객체를 이동하는 모습도 보게 됩니다.)
Go와 C# 문서에서도 두 언어의 동작에 관해 이 정도는 알려 주겠지만, 이렇게 직접 시연으로 보는 것도 좋다고 생각합니다.
이제 Go 메모리가 어떻게 할당되는지 보았으니, 어떻게 정리되는지 살펴보겠습니다.
가비지 컬렉터는 특정 루트(예: 전역 변수와 지역 변수)에서 시작해, 역사적으로 Go에서는 GC가 접근 가능한 모든 객체를 방문할 때까지 각 포인터를 따라갔습니다. 이것이 표시 단계입니다. 이어 두 번째 순회에서 GC는 방문되지 않은 할당 객체를 해제합니다. 이제 해제된 이 객체들은 표시 단계에서 루트 트리로부터 접근할 수 없었으므로, 정의상 죽은 객체입니다. 이것이 청소 단계입니다.
서로 다른 크기의 객체 B/C/D를 가리키는 객체 A가 있을 때 문제가 생깁니다. Go에서는 서로 다른 크기의 객체가 메모리의 서로 다른 구역에 할당됩니다. 또는 아주 다른 시점에 생성된 다른 객체 A를 가리키는 객체 A가 있는 경우에도 마찬가지입니다. 이들은 메모리의 아주 다른 부분에 존재하게 됩니다. 두 경우 모두 GC가 포인터를 따라가면 무작위 메모리 접근이 발생하며, 이는 측정 가능한 수준으로 캐시 친화성이 떨어집니다.
Green Tea에서 Go는 이제 포인터를 하나 볼 때마다 대략 그 포인터를 따라가는 대신, 객체와 포인터를 찾기 위해 메모리 span을 스캔하고 발견한 포인터를 바탕으로 나중에 스캔할 span을 큐에 넣습니다. 그리고 Go 자체에 패치를 적용하지 않고서는(각 객체를 방문할 때 표시 경로를 관찰할 수 있도록) 이 무작위 접근 동작을 보여 줄 수 없지만, perf로 명령어 천 개당 캐시 미스가 줄어들고 전체 프로그램 실행도 빨라지는 것을 관찰할 수 있습니다.
다음은 워크로드의 의사 코드입니다.
struct Node {a,b,c,d *Node}
mode = packed | scattered
nodes = new [2_000_000]*Node
for i in 0..nodes.len:
nodes[i] = Node{
a: nodes[(mode == packed ? i + 1 : rand()) % nodes.len],
b: nodes[(mode == packed ? i + 2 : rand()) % nodes.len],
c: nodes[(mode == packed ? i + 3 : rand()) % nodes.len],
d: nodes[(mode == packed ? i + 4 : rand()) % nodes.len]
}
for i in 0..100:
trigger_gc()
keepalive(nodes) # prevent `nodes` from being garbage collected
프로그램 측정을 조금 더 공정하게 만들기 위해(흩어진 버전은 난수를 생성하는 데 상당한 작업을 해야 합니다) 노드 인덱스 오프셋 생성은 분리하겠습니다.
import array
import random
import sys
n = 2_000_000
order = sys.argv[1] if len(sys.argv) > 1 else ""
if order == "packed":
a = array.array("I", ((i + k) % n for i in range(n) for k in (1, 2, 3, 4)))
elif order == "scattered":
r = random.Random(1)
a = array.array("I", (r.randrange(n) for _ in range(n * 4)))
else:
sys.exit("usage: gen.py packed|scattered")
assert a.itemsize == 4 and sys.byteorder == "little" # matches Go's uint32 cast
with open(order+".idx", "wb") as f:
a.tofile(f)
generate_indexes.py
그러면 Go 워크로드는 다음과 같습니다.
package main
import (
"io"
"os"
"runtime"
"unsafe"
)
type Node struct {
a, b, c, d *Node
}
func main() {
n := 2_000_000
raw, err := io.ReadAll(os.Stdin)
if err != nil {
panic(err)
}
idx := unsafe.Slice((*uint32)(unsafe.Pointer(&raw[0])), n*4)
nodes := make([]*Node, n)
for i := range nodes {
nodes[i] = &Node{}
}
for i, nd := range nodes {
nd.a = nodes[idx[i*4]]
nd.b = nodes[idx[i*4+1]]
nd.c = nodes[idx[i*4+2]]
nd.d = nodes[idx[i*4+3]]
}
for i := 0; i < 100; i++ {
runtime.GC()
}
runtime.KeepAlive(nodes) // keep `nodes` from seeming to fall out of scope
}
readorder.go
이제 Python 스크립트로 인덱스 파일을 생성합니다. 그런 다음 Green Tea를 사용하는 것과 사용하지 않는 것, 두 버전의 Go 워크로드를 빌드합니다.
python3 generate_indexes.py scattered
python3 generate_indexes.py packed
go build -o readorder_greentea readorder.go
GOEXPERIMENT=nogreenteagc go build -o readorder_oldgc readorder.go
캐시 미스 정보를 수집하면서 perf로 두 가비지 컬렉터와 두 워크로드의 시간을 측정해 보겠습니다.
$ for bin in readorder_oldgc readorder_greentea; do
for input in packed.idx scattered.idx; do
echo "=== $bin < $input ==="
perf stat -e cache-references,cache-misses -r 5 \
sh -c "exec ./$bin < $input" > /dev/null
done
done
=== readorder_oldgc < packed.idx ===
Performance counter stats for 'sh -c exec ./readorder_oldgc < packed.idx' (5 runs):
1,130,709,755 cache-references ( +- 0.70% )
290,434,782 cache-misses # 25.69% of all cache refs ( +- 1.02% )
4.230 +- 0.145 seconds time elapsed ( +- 3.44% )
=== readorder_oldgc < scattered.idx ===
Performance counter stats for 'sh -c exec ./readorder_oldgc < scattered.idx' (5 runs):
13,247,268,612 cache-references ( +- 0.38% )
2,325,799,796 cache-misses # 17.56% of all cache refs ( +- 0.15% )
11.052 +- 0.154 seconds time elapsed ( +- 1.39% )
=== readorder_greentea < packed.idx ===
Performance counter stats for 'sh -c exec ./readorder_greentea < packed.idx' (5 runs):
481,414,281 cache-references ( +- 0.27% )
257,894,055 cache-misses # 53.57% of all cache refs ( +- 0.04% )
2.69560 +- 0.00385 seconds time elapsed ( +- 0.14% )
=== readorder_greentea < scattered.idx ===
Performance counter stats for 'sh -c exec ./readorder_greentea < scattered.idx' (5 runs):
3,398,491,016 cache-references ( +- 1.02% )
2,195,902,796 cache-misses # 64.61% of all cache refs ( +- 0.10% )
6.9610 +- 0.0108 seconds time elapsed ( +- 0.16% )
새 GC에서 각 워크로드 모두 아주 분명한 개선을 볼 수 있습니다. 하지만 새 GC에서는 캐시 미스가 증가한 듯합니다. 예상과 다릅니다.
여기에는 두 가지 요인이 있습니다. 첫째, perf의 cache-references와 cache-misses는 대개 L3 캐시에 대응합니다. 따라서 새 GC에서 L3 캐시 미스 비율이 올라갔다 해도 L1 또는 L2 캐시 수준의 동작에 관해서는 아무것도 알려 주지 않습니다. 게다가 우리는 직접 속도 향상을 확인했습니다. 즉, 뭔가를 놓치고 있습니다.
둘째, 기존 GC와 새 GC 사이에서 프로그램 실행 시간이 크게 달라졌고 이를 정규화하지 않았습니다. perf에는 표준화된 측정값인 MPKI(Misses Per Kilo Instructions)를 계산하는 기준으로 쓸 수 있는 instructions 메트릭이 있습니다.
그러므로 perf를 다시 실행해 instructions도 요청하고 MPKI를 직접 계산해 보겠습니다.
import json, subprocess
for binary in ["readorder_oldgc", "readorder_greentea"]:
for inp in ["packed.idx", "scattered.idx"]:
out = subprocess.run(
["perf", "stat", "-j", "-e", "instructions,cache-misses", "-r", "5",
"sh", "-c", f"exec ./{binary} < {inp}"],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True).stderr
c = {}
for line in out.splitlines():
line = line.strip().lstrip("[").rstrip("],")
if line.startswith("{"):
r = json.loads(line)
c[r["event"].split(":")[0]] = float(r["counter-value"])
print(f"{binary:<20} {inp:<14} {1000 * c['cache-misses'] / c['instructions']:6.2f} MPKI")
perf.py
실행합니다.
$ python3 perf.py
readorder_oldgc packed.idx 1.59 MPKI
readorder_oldgc scattered.idx 12.70 MPKI
readorder_greentea packed.idx 1.81 MPKI
readorder_greentea scattered.idx 15.39 MPKI
여전히 예상한 결과가 아닙니다! 새 GC에서 L3 MPKI가 실제로 증가했습니다. 하지만 성능 향상은 직접 보았습니다!
이 시점에서 캐시 개선 효과를 계속 추적하려면 베어메탈 x86/amd64 Linux 머신으로 옮겨야 합니다. 대부분의 가상 머신은 L1 이벤트에 필요한 PMU 카운터를 노출하지 않기 때문입니다.
Vultr 베어메탈 머신을 확보하고 계속 진행했습니다. L1 메트릭을 얻기 위해 perf에 L1-dcache-loads 및 L1-dcache-load-misses를 요청하겠습니다. 그다음 L1 캐시와 L3 캐시 모두에 대해 MPKI를 계산하겠습니다.
import json, statistics, subprocess
print(f"{'binary':<21}{'ordering':<12}{'elapsed(s)':>10}{'L1miss%':>9}"
f"{'L1-MPKI':>9}{'L3miss%':>9}{'L3-MPKI':>10}")
print("-" * 80)
for binary in ["readorder_oldgc", "readorder_greentea"]:
for order in ["packed", "scattered"]:
runs = []
for _ in range(5):
out = subprocess.run(
["perf", "stat", "-j", "-e",
"instructions,duration_time,L1-dcache-loads,L1-dcache-load-misses,"
"cache-references,cache-misses",
"sh", "-c", f"exec ./{binary} < {order}.idx"],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True).stderr
c = {}
for line in out.splitlines():
line = line.strip().lstrip("[").rstrip("],")
if line.startswith("{"):
r = json.loads(line)
c[r["event"].split(":")[0]] = float(r["counter-value"])
runs.append(c)
secs = [r["duration_time"] / 1e9 for r in runs]
ins = statistics.fmean(r["instructions"] for r in runs)
l1 = statistics.fmean(r["L1-dcache-load-misses"] for r in runs)
l1l = statistics.fmean(r["L1-dcache-loads"] for r in runs)
l3 = statistics.fmean(r["cache-misses"] for r in runs)
l3r = statistics.fmean(r["cache-references"] for r in runs)
el = f"{statistics.fmean(secs):.2f}±{statistics.stdev(secs):.2f}"
print(f"{binary:<21}{order:<12}{el:>10}"
f"{100 * l1 / l1l:>9.1f}{1000 * l1 / ins:>9.2f}"
f"{100 * l3 / l3r:>9.1f}{1000 * l3 / ins:>10.2f}")
perf2.py
실행해 봅시다.
$ python3 perf2.py
binary ordering elapsed(s) L1miss% L1-MPKI L3miss% L3-MPKI
--------------------------------------------------------------------------------
readorder_oldgc packed 4.47±0.32 0.9 2.23 26.2 1.61
readorder_oldgc scattered 11.44±0.85 12.9 31.57 17.5 12.76
readorder_greentea packed 2.70±0.01 1.0 1.98 53.8 1.81
readorder_greentea scattered 7.01±0.04 7.3 14.06 63.2 15.37
이제 마침내 예상하던 결과가 보이기 시작합니다. L1 캐시 미스 비율은 그대로이거나 감소하는 반면, _L1 MPKI_는 더 뚜렷하게 감소합니다. 더 많은 읽기가 L1(그리고 아마 L2) 캐시에 들어맞아 L3 캐시까지 갈 필요조차 없었습니다. L3 캐시 미스의 중요성은 낮아집니다. 그러므로 이것이 Green Tea GC가 개선한 영역 중 적어도 하나입니다.
이제 Go 가비지 컬렉터가 여전히 어려움을 겪는 영역 중 하나를 살펴보겠습니다.
Go는 메모리를 압축하거나 단편화 해소하기 위해 절대 이동하지 않으므로, 객체의 엄청난 비율을 해제했는데도 Go가 사용하지 않는 메모리를 완전히 회수하지 못하는, 얼핏 터무니없어 보이는 상황에 쉽게 빠질 수 있습니다.
S/M/L 객체의 메모리 할당을 그렸던 처음 프로그램으로 돌아가겠습니다. 이번에는 객체를 할당한 뒤 그중 90%를 해제하겠습니다. 메모리 사용량이 90% 줄어들기를 바라겠지만, Go가 객체를 압축하거나 이동하지 않으므로 실제로는 그렇게 되지 않는다는 것을 보게 됩니다.
package main
import (
"bytes"
"cmp"
"fmt"
"math/rand"
"reflect"
"runtime"
"runtime/debug"
"slices"
)
type (
Small struct{ _ [32]byte } // 32 bytes
Medium struct{ _ [64]byte } // 64 bytes
Large struct{ _ [128]byte } // 128 bytes
)
type object struct {
addr uintptr
size int
name byte // 'S' / 'M' / 'L'
}
func main() {
allocs := []func() any{
func() any { return new(Small) },
func() any { return new(Medium) },
func() any { return new(Large) },
}
live := make([]any, 50000) // keep refs so GC can't reclaim, and so we know each type
for i := range live {
live[i] = allocs[rand.Intn(len(allocs))]()
}
for pass := 0; pass < 2; pass++ {
if pass == 1 {
for i := range live {
if i%10 != 0 { // free 90% of the objects
live[i] = nil
}
}
runtime.GC() // but the survivors can't be moved,
debug.FreeOSMemory() // so nothing closes up and nothing goes back
} else {
runtime.GC()
}
objs := make([]object, 0, len(live))
for _, o := range live {
if o == nil {
continue
}
t := reflect.TypeOf(o).Elem()
objs = append(objs, object{reflect.ValueOf(o).Pointer(), int(t.Size()), t.Name()[0]})
}
slices.SortFunc(objs, func(a, b object) int { return cmp.Compare(a.addr, b.addr) })
fmt.Printf("\n=== pass %d (%d live, base 0x%x) ===\n", pass, len(objs), objs[0].addr)
draw(window(objs, 16*1024)) // first 16 KiB of the heap, same region both passes
stats(objs)
}
runtime.KeepAlive(live)
}
// window returns the objects sitting in the first n bytes of the heap.
func window(objs []object, n uintptr) []object {
end := objs[0].addr + n
for i, o := range objs {
if o.addr >= end {
return objs[:i]
}
}
return objs
}
func stats(objs []object) {
const span = 8192 // Go's span/page granularity
liveBytes := 0
spans := map[uintptr]bool{}
for _, o := range objs {
liveBytes += o.size
spans[o.addr&^(span-1)] = true
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
kib := func(x uint64) float64 { return float64(x) / 1024 }
fmt.Printf(" live data %8.1f KiB\n", float64(liveBytes)/1024)
fmt.Printf(" spans pinned %8d (%d if the survivors were packed)\n",
len(spans), (liveBytes+span-1)/span)
fmt.Printf(" runtime HeapInuse %.1f KiB | HeapIdle %.1f KiB | HeapReleased %.1f KiB\n",
kib(m.HeapInuse), kib(m.HeapIdle), kib(m.HeapReleased))
}
func draw(objs []object) {
const cell, width = 32, 60
last := objs[len(objs)-1]
base := objs[0].addr
grid := make([]byte, int((last.addr+uintptr(last.size)-base)/cell))
for i := range grid {
grid[i] = '.'
}
for _, o := range objs {
c0 := int((o.addr - base) / cell)
grid[c0] = o.name
for k := 1; k < o.size/cell; k++ {
grid[c0+k] = '-'
}
}
prev := -1
for off := 0; off < len(grid); off += width {
row := grid[off:min(off+width, len(grid))]
if len(bytes.Trim(row, ".")) == 0 { // row holds none of our objects
continue
}
if prev >= 0 && off != prev+width {
fmt.Println(" ...")
}
fmt.Printf("0x%09x %s\n", base+uintptr(off)*cell, row)
prev = off
}
}
heapwalk_free.go
실행합니다.
$ go run heapwalk_free.go
=== pass 0 (50000 live, base 0x10a2674ac000) ===
0x10a2674ac000 L---L---L---L---L---L---L---L---L---L---L---L---L---L---L---
0x10a2674ac780 L---L---L---L---L---L---L---L---L---L---L---L---L---L---L---
0x10a2674acf00 L---L---L---L---L---L---L---L---L---L---L---L---L---L---L---
0x10a2674ad680 L---L---L---L---L---L---L---L---L---L---L---L---L---L---L---
0x10a2674ade00 L---L---L---....L---L---L---L---L---L---L---L---L---L---L---
0x10a2674ae580 L---L---L---L---L---L---L---L---L---L---L---L---L---L---L---
0x10a2674aed00 L---L---L---L---L---L---L---L---L---L---L---L---L---L---L---
0x10a2674af480 L---L---L---L---L---L---L---L---L---L---L---L---L---L---L---
0x10a2674afc00 L---L---L---L---L---L---L---
live data 3639.9 KiB
spans pinned 464 (455 if the survivors were packed)
runtime HeapInuse 6320.0 KiB | HeapIdle 5520.0 KiB | HeapReleased 5488.0 KiB
=== pass 1 (5000 live, base 0x10a2674acc00) ===
0x10a2674acc00 L---........................L---............................
0x10a2674ad380 ................L---............................L---........
0x10a2674adb00 L---....................................................L---
0x10a2674ae280 ................L---........L---............L---............
0x10a2674aea00 ............L---............................................
0x10a2674af180 ........L---............L---....................L---........
0x10a2674af900 ............................L---
live data 364.6 KiB
spans pinned 463 (46 if the survivors were packed)
runtime HeapInuse 6320.0 KiB | HeapIdle 5552.0 KiB | HeapReleased 5512.0 KiB
흥미로운 점은 (단순한 시나리오에서는) 단편화를 피하고 메모리를 회수하기 위해 우리가 직접 객체를 “이동”(정확히는 복사)할 수 있다는 것입니다.
package main
import (
"bytes"
"cmp"
"fmt"
"math/rand"
"reflect"
"runtime"
"runtime/debug"
"slices"
"unsafe"
)
type (
Small struct{ _ [32]byte } // 32 bytes
Medium struct{ _ [64]byte } // 64 bytes
Large struct{ _ [128]byte } // 128 bytes
)
type object struct {
addr uintptr
size int
name byte // 'S' / 'M' / 'L'
}
func main() {
allocs := []func() any{
func() any { return new(Small) },
func() any { return new(Medium) },
func() any { return new(Large) },
}
live := make([]any, 50000) // keep refs so GC can't reclaim, and so we know each type
for i := range live {
live[i] = allocs[rand.Intn(len(allocs))]()
}
// pass 2 moves the survivors in here, by hand
var packedS []Small
var packedM []Medium
var packedL []Large
for pass := 0; pass < 3; pass++ {
switch pass {
case 1:
for i := range live {
if i%10 != 0 { // free 90% of the objects
live[i] = nil
}
}
case 2:
for i, o := range live {
switch v := o.(type) {
case *Small:
packedS = append(packedS, *v)
case *Medium:
packedM = append(packedM, *v)
case *Large:
packedL = append(packedL, *v)
}
live[i] = nil
}
}
runtime.GC()
debug.FreeOSMemory()
objs := make([]object, 0, len(live))
for i := range packedS {
objs = append(objs, object{uintptr(unsafe.Pointer(&packedS[i])), 32, 'S'})
}
for i := range packedM {
objs = append(objs, object{uintptr(unsafe.Pointer(&packedM[i])), 64, 'M'})
}
for i := range packedL {
objs = append(objs, object{uintptr(unsafe.Pointer(&packedL[i])), 128, 'L'})
}
for _, o := range live {
if o == nil {
continue
}
t := reflect.TypeOf(o).Elem()
objs = append(objs, object{reflect.ValueOf(o).Pointer(), int(t.Size()), t.Name()[0]})
}
slices.SortFunc(objs, func(a, b object) int { return cmp.Compare(a.addr, b.addr) })
fmt.Printf("\n=== pass %d (%d live, base 0x%x) ===\n", pass, len(objs), objs[0].addr)
draw(window(objs, 16*1024)) // first 16 KiB of the survivors' range
stats(objs)
}
runtime.KeepAlive(live)
runtime.KeepAlive(packedS)
runtime.KeepAlive(packedM)
runtime.KeepAlive(packedL)
}
// window returns the objects sitting in the first n bytes of the range.
func window(objs []object, n uintptr) []object {
end := objs[0].addr + n
for i, o := range objs {
if o.addr >= end {
return objs[:i]
}
}
return objs
}
func stats(objs []object) {
const span = 8192 // Go's span/page granularity
liveBytes := 0
spans := map[uintptr]bool{}
for _, o := range objs {
liveBytes += o.size
spans[o.addr&^(span-1)] = true
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
kib := func(x uint64) float64 { return float64(x) / 1024 }
fmt.Printf(" live data %8.1f KiB\n", float64(liveBytes)/1024)
fmt.Printf(" spans pinned %8d (%d if the survivors were packed)\n",
len(spans), (liveBytes+span-1)/span)
fmt.Printf(" runtime HeapInuse %.1f KiB | HeapIdle %.1f KiB | HeapReleased %.1f KiB\n",
kib(m.HeapInuse), kib(m.HeapIdle), kib(m.HeapReleased))
}
func draw(objs []object) {
const cell, width = 32, 60
last := objs[len(objs)-1]
base := objs[0].addr
grid := make([]byte, int((last.addr+uintptr(last.size)-base)/cell))
for i := range grid {
grid[i] = '.'
}
for _, o := range objs {
c0 := int((o.addr - base) / cell)
grid[c0] = o.name
for k := 1; k < o.size/cell; k++ {
grid[c0+k] = '-'
}
}
prev := -1
for off := 0; off < len(grid); off += width {
row := grid[off:min(off+width, len(grid))]
if len(bytes.Trim(row, ".")) == 0 { // row holds none of our objects
continue
}
if prev >= 0 && off != prev+width {
fmt.Println(" ...")
}
fmt.Printf("0x%09x %s\n", base+uintptr(off)*cell, row)
prev = off
}
}
heapwalk_free_manual.go
그리고 실행합니다.
$ go run heapwalk_free_manual.go
=== pass 0 (50000 live, base 0xa615561a0c0) ===
0xa615561a0c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa615561a840 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa615561afc0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa615561b740 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa615561bec0 M-M-M-......................................................
...
0xa615561dcc0 ............................SSSS
live data 3654.9 KiB
spans pinned 465 (457 if the survivors were packed)
runtime HeapInuse 6224.0 KiB | HeapIdle 5680.0 KiB | HeapReleased 5632.0 KiB
=== pass 1 (5000 live, base 0xa615561a0c0) ===
0xa615561a0c0 M-..........................M-......M-......................
0xa615561a840 ..M-......M-..M-......M-..........M-........................
0xa615561afc0 ..............M-..................................M-........
0xa615561b740 ....M-
live data 373.0 KiB
spans pinned 465 (47 if the survivors were packed)
runtime HeapInuse 6232.0 KiB | HeapIdle 5672.0 KiB | HeapReleased 5672.0 KiB
=== pass 2 (5000 live, base 0xa6155710000) ===
0xa6155710000 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155710780 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155710f00 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155711680 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155711e00 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155712580 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155712d00 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155713480 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
0xa6155713c00 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-
live data 373.0 KiB
spans pinned 48 (47 if the survivors were packed)
runtime HeapInuse 2768.0 KiB | HeapIdle 9104.0 KiB | HeapReleased 9040.0 KiB
꽤 흥미롭습니다.
하지만 C# 예제에서 객체의 90%를 정리하는 경우와 비교해 보겠습니다.
#nullable enable
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
var allocs = new Func<object>[] { () => new Small(), () => new Medium(), () => new Large() };
// A reference on 64-bit .NET is a plain 8-byte pointer, so reinterpreting one
// with Unsafe.As gives the object's address. Object sizes are measured from
// the heap: allocate several, the smallest gap between consecutive addresses
// is the (aligned) object size, header included.
var size = new Dictionary<Type, int>();
foreach (var make in allocs)
{
var keep = new object[16];
var a = new nint[keep.Length];
for (int i = 0; i < keep.Length; i++) keep[i] = make();
for (int i = 0; i < keep.Length; i++) a[i] = Unsafe.As<object, nint>(ref keep[i]);
Array.Sort(a);
nint best = nint.MaxValue;
for (int i = 1; i < a.Length; i++)
if (a[i] - a[i - 1] > 0 && a[i] - a[i - 1] < best) best = a[i] - a[i - 1];
size[keep[0].GetType()] = (int)best;
}
var live = new object?[50_000]; // the only refs to our objects: nulling one frees it
var rnd = new Random();
for (int i = 0; i < live.Length; i++) live[i] = allocs[rnd.Next(allocs.Length)]();
for (int pass = 0; pass < 2; pass++)
{
if (pass == 1)
for (int i = 0; i < live.Length; i++)
if (i % 10 != 0) live[i] = null; // free 90% of the objects
// Full compacting collection that also returns freed memory to the OS.
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive);
int n = 0;
foreach (var o in live) if (o != null) n++;
var addrs = new nint[n];
GC.TryStartNoGCRegion(1 << 20); // addresses are only valid until the next GC
for (int i = 0, j = 0; i < live.Length; i++)
if (live[i] != null) addrs[j++] = Unsafe.As<object?, nint>(ref live[i]);
GC.EndNoGCRegion();
var objs = new (nint Addr, int Size, char Name)[n];
for (int i = 0, j = 0; i < live.Length; i++)
if (live[i] != null)
{
objs[j] = (addrs[j], size[live[i]!.GetType()], live[i]!.GetType().Name[0]);
j++;
}
Array.Sort(objs, (x, y) => x.Addr.CompareTo(y.Addr));
int w = 0; // the objects sitting in the first 16 KiB of the range
while (w < objs.Length && objs[w].Addr < objs[0].Addr + 16 * 1024) w++;
Console.WriteLine($"\n=== pass {pass} ({n} live, base 0x{(long)objs[0].Addr:x}) ===");
Draw(objs[..w]);
Stats(objs);
}
GC.KeepAlive(live); // keep `live` from seeming to fall out of scope
static void Draw((nint Addr, int Size, char Name)[] objs)
{
const int cell = 32, width = 60; // cell = the smallest object's size
var last = objs[^1];
nint b = objs[0].Addr;
var grid = new char[(last.Addr + last.Size - b) / cell];
Array.Fill(grid, '.');
foreach (var o in objs)
{
int c0 = (int)((o.Addr - b) / cell);
grid[c0] = o.Name;
for (int k = 1; k < o.Size / cell; k++) grid[c0 + k] = '-';
}
int prev = -1;
for (int off = 0; off < grid.Length; off += width)
{
var row = new string(grid, off, Math.Min(width, grid.Length - off));
if (row.Trim('.').Length == 0) continue; // row holds none of our objects
if (prev >= 0 && off != prev + width) Console.WriteLine(" ...");
Console.WriteLine($"0x{(long)b + (long)off * cell:x9} {row}");
prev = off;
}
}
static void Stats((nint Addr, int Size, char Name)[] objs)
{
const int chunk = 8192; // 8KiB = Go's span size, for an apples-to-apples density measure
long liveBytes = 0;
var chunks = new HashSet<nint>();
foreach (var o in objs)
{
liveBytes += o.Size;
chunks.Add(o.Addr & ~(nint)(chunk - 1));
}
Console.WriteLine($" live data {liveBytes / 1024.0,8:f1} KiB");
Console.WriteLine($" 8KiB chunks {chunks.Count,8} ({(liveBytes + chunk - 1) / chunk} if the survivors were packed)");
}
class Small { public long a, b; } // 32 bytes: 16-byte header + 2 longs
class Medium { public long a, b, c, d, e, f; } // 64 bytes
class Large { public long a, b, c, d, e, f, g, h, i, j, k, l, m, n; } // 128 bytes
HeapWalkFree.cs
컴파일하고 실행합니다.
$ dotnet run HeapWalkFree.cs
=== pass 0 (50000 live, base 0x7e928000aad0) ===
0x7e928000aad0 M-M-L---M-M-SSL---M-L---M-M-L---M-L---M-M-SM-L---L---SM-M-M-
0x7e928000b250 SM-SL---L---L---L---SM-L---SM-SM-M-M-M-L---SSM-L---L---L---L
0x7e928000b9d0 ---M-SM-M-M-SSL---M-SSL---L---L---SM-M-M-M-SL---M-M-SL---M-L
0x7e928000c150 ---M-SL---M-L---L---L---L---M-L---SM-SM-L---M-L---L---L---L-
0x7e928000c8d0 --M-SL---M-M-SSSL---M-SL---SM-L---L---L---L---M-SM-M-M-M-M-L
0x7e928000d050 ---L---SL---L---M-SL---SSSL---M-L---M-SM-M-L---SL---L---SL--
0x7e928000d7d0 -.L---M-M-L---SSL---SL---L---M-SL---M-SM-L---M-L---M-SM-M-SSS
0x7e928000df50 SL---M-M-M-M-SM-M-L---L---M-M-M-SL---L---M-SSM-L---L---M-M-S
0x7e928000e6d0 L---L---L---M-SM-SM-SM-M-L---L---
live data 3646.1 KiB
8KiB chunks 458 (456 if the survivors were packed)
=== pass 1 (5000 live, base 0x7e9282c0aa70) ===
0x7e9282c0aa70 M-M-L---L---M-M-SL---M-M-M-M-M-SL---SSSM-M-L---L---M-SL---SS
0x7e9282c0b1f0 M-L---M-L---M-L---L---L---SL---SSM-L---L---SSL---L---SM-SSL-
0x7e9282c0b970 --SSSM-L---SSSSSL---M-SL---SSM-L---M-M-L---SSM-SSL---M-M-L--
0x7e9282c0c0f0 -M-SSM-SM-M-SSM-M-M-M-L---L---L---SM-M-SSL---L---M-M-M-M-L--
0x7e9282c0c870 -SM-L---L---M-L---M-SL---SSM-L---L---SL---L---M-L---L---SM-S
0x7e9282c0cff0 M-L---L---M-SL---SL---SSM-M-SL---L---M-L---SSSSL---SM-M-SSM-
0x7e9282c0d770 M-L---L---L---M-M-L---L---SL---SM-SSM-SM-L---SL---M-M-SL---L
0x7e9282c0def0 ---L---SM-SM-SM-SM-M-L---L---L---L---SL---SM-SSM-SM-SM-L---L
0x7e9282c0e670 ---L---M-M-M-M-SM-SSL---SL---L---
live data 369.2 KiB
8KiB chunks 47 (47 if the survivors were packed)
상당히 좋아 보입니다!
실수를 발견했나요? 질문이나 의견이 있나요? 편집자에게 보내 주세요.