Go의 타입 레이아웃과 GC 셰이프, 슬라이스/문자열, 리플렉션을 통한 동적 배열, 인터페이스의 코드 생성과 타입 단언 캐시, 간접 인터페이스(박싱), 그리고 함수 포인터/클로저 ABI까지 구현 세부를 x86 어셈블리와 함께 파헤친 노트.
최근 들어 Go 코드를 조금씩 쓰다 보니, 숨은 할당을 최소화하고 전반적으로 최적화기에 친절하게 코드를 쓰는 데 도움이 되는 여러 가지 재미있는 “레이아웃 비밀”을 주워 담게 되었습니다. 이 글은 그에 관한 메모 모음입니다.
이 글은 Go의 구현 세부 내용에 관한 것이므로, 여기에 의존하면 언제든 부러질 수 있습니다. 한편으로는 Hyrum’s law라는 것도 있으니, 모험이 그리 나쁘지만은 않을지도요. 어차피 런타임 심볼에 대한 //go:linkname으로 사람들이 만들어 놓은 난장판을 제대로 청소할 수 있을 것 같지도 않고…
제 다른 글들처럼, 어셈블리를 읽을 수 있는 기본적인 친숙함을 전제로 합니다. 이 글에서는 x86을 쓰지만, 복습이 필요하면 제 RISC-V 글을 참고해 보세요.
타입 레이아웃에서 가장 기본적인 Go 고유 개념은 타입의 셰이프(shape)입니다. 이것은 unsafe 패키지를 통해 새어 나오는 Go GC의 구현 세부입니다.
대부분의 네이티브 언어와 마찬가지로, 모든 Go 타입에는 크기(그 타입이 메모리에서 차지하는 바이트 수)와 정렬(그 타입을 가리키는 모든 포인터가 나누어 떨어져야 하는 2의 거듭제곱)이 있습니다. Go 역시 다른 많은 언어처럼 크기가 정렬의 배수일 것을 요구합니다. 즉, 크기는 그 타입의 배열에서의 스트라이드와 같습니다.
타입의 크기와 정렬은 unsafe.Sizeof와 unsafe.Alignof 인트린식으로 조회할 수 있습니다. 제네릭 코드에서는 다루기 불편하므로, 저는 보통 몇 가지 헬퍼를 정의해 둡니다1:
func Size[T any]() int {
var v T
return int(unsafe.Sizeof(v))
}
func Align[T any]() int {
var v T
return int(unsafe.Alignof(v))
}
Go
이 둘을 합쳐 타입의 레이아웃(layout)이라고 부릅니다(많은 네이티브 언어에서 쓰는 용어). 하지만 타입의 셰이프(shape)에는 그중 어느 부분이 포인터를 담고 있는지도 기록됩니다. 이는 GC가 볼 수 있는 메모리(전역, 힙, 스택 루트 등)가 타입 정보를 가지며, GC가 그 타입의 어느 부분이 포인터인지 알아야 추적할 수 있기 때문입니다.
모든 포인터는 같은 크기와 정렬(시스템에 따라 4 또는 8바이트)을 가지므로, 타입의 포인터 단어들은 비트셋으로 표현할 수 있습니다. 즉 타입 내 4 또는 8바이트마다 1비트씩 할당합니다. 실제로 GC가 사용하는 표현이 바로 이것입니다2.
특히, 어떤 필드를 unsafe.Pointer로 해석할지 uintptr로 해석할지는 타입의 정적인 속성이라는 뜻이기도 합니다. 이 제약은 뒤에서 인터페이스를 다룰 때 몇 가지 레이아웃 최적화를 방해하는 요인이 됩니다.
Go는 슬라이스와 문자열의 레이아웃을 매우 공개적으로 다룹니다. 슬라이스는 다음과 같습니다.
type slice[T] struct {
data *T
len, cap int
}
Go
len과 cap은 동명의 내장 함수로 꺼낼 수 있고, data는 unsafe.SliceData로 얻을 수 있습니다(슬라이스가 비어 있지 않다면 &s[0]도 쓸 수 있지만 경계 검사가 듭니다).
문자열은 []byte와 동일한 레이아웃에서 용량(capacity)만 빠진 형태입니다:
type string struct {
data *byte
len int
}
Go
사실상 슬라이스지만, Go는 문자열을 미묘하게 다르게 취급합니다. 문자열은 comparable이므로 맵 키로 쓸 수 있습니다. 또한 불변이기 때문에 몇 가지 최적화가 가능합니다. 불변이기 때문에 comparable이기도 합니다. Go는 C에서의 const를 유지하지 않는 실수를 했지만, 맵 키만큼은 정말로 const이길 원합니다.
슬라이스가 가리키는 데이터를 문자열로 앨리어싱하는 것을 막는 것은 없습니다. 실제로 strings.Builder는 String()에서 복사를 피하기 위해 그렇게 합니다. 약간의 unsafe로 쉽게 구현할 수 있습니다:
func StringAlias(data []byte) string {
return unsafe.String(unsafe.SliceData(data), len(data))
}
Go
이렇게 해도, 반환된 문자열이 접근 가능한 동안 데이터가 변경되지 않는 한 완전히 안전합니다. 이를 통해 몇 가지 주의사항만 지키면 거의 어떤 슬라이스 타입이든 맵 키로 사용할 수 있습니다.
new가 반환하는 메모리를 0으로 채운다고 약속하지 않습니다.이것 말고도 다른 방법이 있습니다. 리플렉션으로 크기가 동적인 배열 타입을 만들 수 있습니다. 예를 들면:
func Slice2Array[T any](s []T) any {
if s == nil { return nil }
var v T
elem := reflect.TypeOf(v)
array := reflect.ArrayOf(len(s), elem)
// 참고: NewAt은 배열이 아니라 포인터를 담은 reflect.Value를 반환합니다!
refl := reflect.NewAt(array, unsafe.SliceData(s))
refl = refl.Elem() // 역참조하여 포인터-투-배열을 얻습니다.
return refl.Interface()
}
Go
이 함수는 타입이 [len(s)]T인 any를 반환합니다. 정적 배열 크기에 대해 타입 단언도 할 수 있습니다. 이 값은 any([...]byte("foo"))로 만들었을 때처럼 map[any]T에 넣기에 적합합니다.
하지만, 코드만 봐서는 전혀 명확하지 않게도, refl.Interface()를 호출하면 배열 전체가 복사됩니다. Interface()는 몇 개의 함수를 거쳐 reflect.packEface()를 호출합니다.
이 함수의 코드는(여기에서 찾을 수 있습니다) 아래에 그대로 싣습니다:
package reflect
// packEface는 v를 빈 인터페이스로 변환합니다.
func packEface(v Value) any {
// v의 타입을 가져옵니다.
t := v.typ()
var i any
e := (*abi.EmptyInterface)(unsafe.Pointer(&i))
// 먼저, 인터페이스의 data 부분을 채웁니다.
switch {
case t.IfaceIndir():
if v.flag&flagIndir == 0 {
panic("bad indir")
}
// Value는 간접(indirect)이고, 우리가 만드는 인터페이스도 간접입니다.
ptr := v.ptr
if v.flag&flagAddr != 0 {
c := unsafe_New(t)
typedmemmove(t, c, ptr)
ptr = c
}
e.Data = ptr
case v.flag&flagIndir != 0:
// Value는 간접이지만 인터페이스는 직접(direct)입니다.
// v.ptr이 가리키는 데이터를 인터페이스의 data 워드에 담아야 합니다.
e.Data = *(*unsafe.Pointer)(v.ptr)
default:
// Value도 직접이고 인터페이스도 직접입니다.
e.Data = v.ptr
}
// 이제 type 부분을 채웁니다. e.word와 e.typ를 채우는 사이에
// 가비지 컬렉터가 부분적으로 만들어진 인터페이스 값을 관찰할 수 있는
// 어떤 동작도 없도록 매우 조심합니다.
e.Type = t
return i
}
Go
이 switch는 인터페이스 데이터 포인터를 정확히 어떻게 계산할지를 결정합니다. (거의 모든) 배열 타입이 t.IfaceIndr()에 대해 true를 반환하므로 첫 번째 케이스가 선택되고, 그 결과 복사가 일어납니다(unsafe_New() 호출 뒤 typedmemmove가 그것입니다). 이 복사는 결과 인터페이스의 값이 변경 불가능하도록 보장하기 위한 것입니다.
자, 만약 우리가 Go의 인터페이스 레이아웃을 알고 있다면, 여기서 해볼 수 있는 게 있을지도 모르겠습니다…
맞습니다, 이 글의 주제가 바로 그것입니다. 런타임의 runtime2.go 파일을 보면(네, 이름이 진짜 그렇습니다), G, P, M에 대한 거대한 스케줄러 타입들 사이에 상황을 훤히 보여주는 몇 개의 구조체가 있습니다:
package runtime
type funcval struct {
fn uintptr
// 가변 크기, 함수별 캡처 데이터가 뒤따릅니다
}
type iface struct {
tab *itab
data unsafe.Pointer
}
type eface struct {
_type *_type
data unsafe.Pointer
}
Go
funcval은 func()의 레이아웃입니다. 이건 뒤에서 다시 이야기하죠. iface는 흔히 말하는 일반 인터페이스의 레이아웃으로, itab(인터페이스 테이블, Go가 말하는 vtable)과 데이터에 대한 포인터로 구성됩니다. eface는 any의 레이아웃입니다(이전 이름이 interface{}였기 때문에 이름이 그렇습니다: e_mpty inter_face).
eface가 별도의 레이아웃을 가지는 것은 최적화입니다. any는 동적 다운캐스트를 위해 존재하므로, 타입을 직접 저장하면 any에 대한 타입 스위치에서 포인터 로드를 한 번 덜 수 있습니다. itab이 무엇인지 살펴보면(사실상 abi.ITab입니다):
package abi
// 비-빈(non-empty) 인터페이스 타입의 첫 단어는 *ITab을 담습니다.
// 여기에는 (Type) 실제 구체 타입, (Inter) 구현 중인 인터페이스 타입,
// 그리고 몇 가지 부수 정보가 기록됩니다.
//
// 가비지 컬렉션되지 않는 메모리에 할당됩니다.
type ITab struct {
Inter *InterfaceType
Type *Type
Hash uint32 // Type.Hash의 복사본. 타입 스위치에서 사용됩니다.
Fun [1]uintptr // fun[0]==0이면 Type이 Inter를 구현하지 않는다는 뜻입니다.
}
Go
ITab에는 any에 있을 타입과 같은 타입 포인터가 들어 있으므로, 인터페이스를 any로 업캐스트하는 함수에 대해 생성되는 코드는 매우 단순해집니다3:
package foo
func Upcast(i MyIface) any {
return i
}
Go
foo.F:
test rax, rax
jeq nil
mov rax, [rax + 8]
nil:
ret
x86 어셈블리
레지스터 ABI에서, x86의 인자(및 반환) 레지스터는 rax, rbx, rcx, rdi, rsi, r8, r9, r10, r11입니다(여기서 rdx는 클로저 캡처 전달에 예약되어 있습니다. 이것은 뒤에서 더 다룹니다. 그리고 r14는 현재 실행 중인 G를 가리키는 포인터를 보유합니다).
*ITab은 rax로, 데이터 포인터는 rbx로 들어옵니다. 먼저 이 인터페이스가 nil인지 확인해야 하는데, itab이 nil이면(또는 any의 경우 타입이 nil이면) nil 인터페이스입니다. nil이면 그냥 반환합니다. rax:rbx에는 이미 nil any의 데이터가 들어 있습니다. 아니면, 오프셋 8의 ITab.Type을 rax에 로드하고 반환합니다.
인터페이스 메서드 호출은 어떻게 동작할까요?
package foo
type MyIface interface {
Method(int) int
}
func Call(m MyIface) int {
return m.Method(42)
}
Go
foo.Call:
cmp rsp, [r14 + 16]
jls grow
push rbp
mov rsp, rbp
add rsp, -16
mov [rsp], rax
mov [rsp + 8], rbx
mov rcx, [rax + 24]
mov rax, rbx
mov rbx, 42
call rcx
add rsp, 16
pop rbp
ret
grow:
nop
mov [rsp + 8], rax
mov [rsp + 16], rbx
call runtime.morestack_noctxt
mov rax, [rsp + 8]
mov rbx, [rsp + 16]
jmp foo.Call
x86 어셈블리
이 함수는 실제보다 훨씬 많은 일을 하는 듯 보입니다. 일부는 프롤로그가 runtime.morestack_noctxt()를 호출해야 해서인데, 이것은 단순히 runtime.morestack을 호출하되 클로저 캡처 파라미터인 rdx를 클로버하는 버전입니다. 핵심은 [rax + 24], 즉 ITab.Fun의 첫 번째 원소를 로드하는 부분입니다. 그리고 rbx의 데이터 포인터를 rax로, 인자를 rbx로 옮긴 뒤 호출을 수행합니다.
업캐스트는 어떨까요? 구체 타입으로의 업캐스트는 매우 간단합니다. 인터페이스에 들어 있는 타입(직접 혹은 *ITab 안의 타입)을 특정한 정적으로 알려진 타입과 비교하면 됩니다. 인터페이스로의 다운캐스트(때때로 _사이드캐스트_라고도 부릅니다)는 훨씬 더 복잡합니다. 약간의 리플렉션이 사실상 필요하기 때문입니다.
package foo
type MyIface interface {
Method(int) int
}
func Downcast(m any) MyIface {
return m.(MyIface)
}
Go
foo.Downcast:
cmp rsp, [r14 + 16]
jls grow
push rpb
mov rbp, rsp
add rsp, -24
mov [rsp], rax
mov [rsp + 8], rbx
test rax, rax
jeq nil
mov rcx, [foo..typeAssert0]
mov rdx, [rcx]
mov rsi, [rax + 16]
hashProbe:
mov rdi, rsi
and rsi, rdx
shl rsi, 4
mov r8, [rcx + rsi + 8]
cmp rax, r8
jeq found
lea rsi, [rdi + 1]
test r8, r8
jnz hashProbe
mov [rsp + 8], rbx
mov rbx, rax
leq rax, [foo..typeAssert0]
call runtime.typeAssert
mov rbx, [rsp + 8]
jmp done
found:
mov rax, [rcx + rsi + 16]
done:
add rsp, 24
pop rpb
ret
nil:
lea rax, [type:foo.MyIface]
call runtime.panicnildottype
grow:
// 위의 foo.Call과 동일합니다.
jmp foo.Downcast
x86 어셈블리
인터페이스 다운캐스트를 요청하면, Go 컴파일러는 abi.TypeAssert 타입의 심볼을 합성해 냅니다. 그 정의는 아래와 같습니다.
package abi
type TypeAssert struct {
Cache *TypeAssertCache
Inter *InterfaceType
CanFail bool
}
type TypeAssertCache struct {
Mask uintptr
Entries [1]TypeAssertCacheEntry
}
type TypeAssertCacheEntry struct {
// 소스 값의 타입(runtime._type*에 해당)
Typ uintptr
// 결과에 사용할 itab(runtime.itab*에 해당)
// CanFail이 설정되어 있고 변환이 실패한다면 nil.
Itab uintptr
}
Go
이 함수는 먼저 rax가 0인지, 즉 nil any인지 확인하고, 그런 경우 패닉을 냅니다(저 호출이 runtime.panicnildottype). 이어서 합성된 전역 변수 foo..typeAssert0를 로드하는데, 여기에 abi.TypeAssert 값이 담겨 있습니다. 그 안의 Cache 필드와, any에 붙은 abi.Type의 Hash 필드를 로드합니다. typeAssert0.Cache.Mask로 하위 비트를 마스킹한 뒤, typeAssert0.Cache.Entries에 있는 매우 단순한 개방 주소법 해시 테이블을 선형 탐사하기 시작합니다.
찾는 타입(주소로 비교합니다)을 가진 TypeAssertCacheEntry를 발견하면, 그 엔트리의 Itab 값을 rax에 로드하여 값을 any에서 MyIface로 바꾸고 종료합니다.
Typ 포인터가 nil인 TypeAssertCacheEntry를 만나면, runtime.typeAssert()로 구현된 슬로 패스로 갈 수밖에 없습니다. 이는 any 안의 타입의 메서드 집합을 탐색하여 동적으로 itab을 만듭니다.
그러고 나서 리플렉션 코드인 runtime.getitab()를 호출하는데, 실제로는 이것이 인터페이스의 메서드와 이름 및 시그니처를 비교하여 런타임에 itab을 만들어 내는 더러운 작업을 수행합니다.
그 다음 전역 itab 캐시에 결과 itab을 밀어 넣는데, 이 캐시는 전역 락으로 보호됩니다! 이 코드에는 무시무시한 원자 연산들이 잔뜩 있습니다. 이 과정에는 사용자에게 타입 단언 실패로 전파될 수 있는 패닉 지점이 여럿 있습니다.
runtime.getitab()이 반환하면, runtime.typeAssert()는 어쩌면4 타입 단언 캐시를 업데이트하고 새로운 itab을 반환합니다. 덕분에 우리 함수의 코드는 hashProbe 루프를 다시 돌지 않고 곧장 반환할 수 있습니다.
이론적으로는 PGO로 캐시를 미리 채울 수도 있겠지만, 컴파일러에서 그런 일을 한다는 흔적은 찾지 못했습니다. 그동안은, 자주 일어나는 타입 단언을 미리 최적화하려면 공통적으로 알려진 타입으로 먼저 단언하는 게 도움이 됩니다:
func DoSomething(r io.Reader) {
var rs io.ReadSeeker
if f, ok := r.(*os.File); ok {
// 먼저 알려진 구현을 확인합니다. 이는 itab의 *abi.Type와
// 포인터 비교 한 번이면 됩니다.
rs = f
} else if f, ok := r.(io.ReadSeeker); ok {
// 인터페이스 타입 단언을 수행합니다. 결국에는 os.File을 배우겠지만
// 위 분기는 그 "워밍업" 시간을 건너뜁니다. 또한 하드웨어 분기 예측기가
// os.File만을 위한 예측 슬롯을 따로 확보하게 해줍니다.
rs = f
} else {
// ...
}
}
Go
참고로 타입 스위치도, 경우들 가운데 인터페이스 타입이 포함되어 있으면 매우 비슷한 캐싱 메커니즘을 사용합니다.
리플렉션으로 배열을 마개조하던 이야기로 돌아가 보면, reflect.Value.Interface()에서 불필요해 보이는 복사가 있었습니다.
이는 인터페이스의 데이터 포인터가 반드시 포인터여야 하기 때문입니다. 예컨대 int를 any에 집어넣으면, Go는 그것을 힙으로 spill(박싱)합니다. 흔히 _박싱_이라고 부르는데, Go 런타임은 이를 “간접 인터페이스(indirect interface)”라고 부릅니다.
package foo
func Int2Any(x int) any {
return x
}
Go
foo.Int2Any:
push rbp
mov rbp, rsp
add rsp, -8
call runtime.convT64
move rbx, rax
lea rax, [type:int]
add rsp, 8
pop rbp
ret
x86 어셈블리
다른 많은 관리형 언어들처럼, Go도 아주 작은 값에 대해서는 전역 배열의 요소를 가리키는 포인터를 반환함으로써 박싱을 건너뛰기도 합니다.
물론 이 박싱을 피할 수 있었을 겁니다. int는 포인터보다 크지 않으니 데이터 포인터 필드에 그대로 쑤셔 넣을 수도 있겠죠. 하지만 GC는 그런 걸 정말 좋아하지 않습니다. GC는 어떤 포인터든 추적할 수 있다고 가정합니다. 인터페이스만 특별 취급해서, 타입/itab 포인터를 보고 데이터 값이 포인터인지 스칼라인지 구분할 수도 있겠지만, 그러면 셰이프 표현과 GC의 추적 코드가 모두 복잡해져 분기가 늘고 추적이 느려집니다.
다만, 인터페이스로 싸는 타입이 포인터인 경우에는 그 포인터 값을 그대로 사용할 수 있습니다.
package foo
func Int2Any(x int) any {
return x
}
Go
foo.Int2Any:
move rbx, rax
lea rax, [type:int]
ret
x86 어셈블리
포인터와 동일한 셰이프를 가진 타입은 전부 간접이 됩니다. map, channel, func가 여기에 포함됩니다. 또한 그런 타입의 길이 1짜리 배열([1]*int, [1]chan error 등)과 그런 타입을 단일 필드로 가지는 struct도 포함됩니다. 흥미롭게도, 포인터 크기 필드 앞에 크기 0 필드를 포함하는 struct는(셰이프는 포인터와 같음에도) 여기에 포함되지 않습니다.
따라서 어떤 타입에 대한 포인터로부터 인터페이스 값을 위조하는 장난은 일반적으로 안전하지 않습니다. 그 타입이 인터페이스에서 간접인지 아닌지는 컴파일러 구현의 미묘한 세부 사항이기 때문입니다.
그리고, 값을 인터페이스로 반환하려면, 그게 인라인될 수 있어야 컴파일러가 힙 할당을 스택으로 승격시킬 수 있다는 점을 명심하십시오.
마지막으로 살펴볼 것은 Go의 함수 포인터입니다. 오랫동안 저는 이게 인터페이스와 같은 레이아웃(클로저 데이터에 대한 포인터 + 하드웨어 함수 포인터)일 거라 가정했습니다.
알고 보니 레이아웃이 더 괴상합니다. 앞서 runtime2.go에서 보았던 runtime.funcval을 다시 떠올려 봅시다.
package runtime
type funcval struct {
fn uintptr
// 가변 크기, 함수별 캡처 데이터가 뒤따릅니다
}
Go
이 특이한 레이아웃은 생성된 어셈블리를 보면 가장 잘 이해됩니다.
package foo
func Call(
f func(int) int,
x int,
) int {
return f(x)
}
Go
foo.Call:
cmp rsp, [r14 + 16]
jls grow
push rpb
mov rpb, rsp
add rsp, -8
mov rcx, [rax]
mov rdx, rax
mov rax, rbx
call rcx
add rsp, 8
pop rbp
ret
grow:
// 이전과 동일.
jmp foo.Call
x86 어셈블리
f를 호출하려면, 먼저 그것을 *funcval로 해석하고 f.fn을 임시 레지스터에 로드합니다. 즉, rax가 가리키는 첫 단어(함수 진입 시 rax에는 f가 들어 있습니다)입니다. 그 다음 f를 클로저 컨텍스트 레지스터인 rdx에 둡니다. 왜 이 추가 마법 레지스터를 쓰는지는 곧 명확해집니다. 그 후 나머지 인자들을 평소 레지스터에 배치하고, f.fn에 저장된 주소로 점프합니다.
f 함수 내부에서는 rdx에서 오프셋을 더해 캡처에 접근합니다. 그런 클로저는 어떤 모양일까요?
package foo
func Capture(x int) func(int) int {
return func(y int) int {
return x * y
}
}
Go
foo.Capture:
cmp rsp, [r14 + 16]
jls grow
push rpb
mov rpb, rsp
add rsp, -16
mov [rsp], rax
lea rax, ["type:noalg.struct { F uintptr; X0 int }"]
call runtime.newobject
lea rcx, foo.Capture.func1
mov [rax], rcx
mov rcx, [rsp]
mov [rax + 8], rcx
add rsp, 16
pop rbp
ret
grow:
// 이전과 동일.
jmp foo.Capture
foo.Capture.func1:
mov rcx, [rdx + 8]
imul rax, rcx
ret
x86 어셈블리
Capture가 하는 일은 int 캡처 하나를 가진 funcval을 할당하는 것뿐입니다. 코드에 보이는 { F uintptr; X0 int }가 그것입니다. 그리고 콜백을 구현하는 Capture.func1의 주소를 F에 넣고, Capture의 인자를 X0에 넣습니다.
그렇다면 함수에 대한 참조를 반환할 때는 어떨까요? 이 경우에는 해당 함수의 주소를 담은 전역에 대한 참조를 반환하는 것이 전부입니다.
package foo
func Capture(x int) func(int) int {
return Id
}
func Id(x int) int {
return x
}
Go
foo.Capture:
lea rax, [foo.Id·f]
ret
foo.Id:
ret
foo.Id·f:
.quad foo.Id
x86 어셈블리
클로저 인자를 일반 함수에서 쓰지 않는 추가 레지스터로 전달하므로, 이 경우에는 썽크(thunk)를 만들 필요가 없습니다.
안타깝게도, 메서드에 대해서는(포인터 리시버 메서드조차) 썽크를 만들어야 합니다. 다음과 같은 상충하는 제약 때문입니다:
결과적으로, 설령 메서드가 rdx로 포인터 리시버를 받는다고 해도, 클로저와 메서드는 그 포인터를 어디로 전달해야 하는지에 대해 서로 의견이 다릅니다.
물론 이 문제를 해결하기 위해 조정할 수 있는 점들이 있습니다. 예를 들어, 모든 funcval이 적어도 하나의 캡처를 갖도록 요구할 수 있습니다. 캡처가 없는 funcval에는 합성된 _ byte 필드를 두는 식으로요. 이는 마지막 필드가 빈(empty) 필드인 비-빈(non-empty) struct가 추가로 _ byte로 패딩되는 것과 비슷합니다. 특정 필드에 대한 포인터가 끝을 지난 포인터가 되는 것을 방지하기 위해서입니다. 대가는 캡처가 없는 클로저의 바이너리 크기가 두 배가 된다는 점입니다.
또 다른 해결책은 GC가 rdx의 포인터를 보지 못하게 하는 것입니다. 이는 어떤 값의 유일한 도달 경로가 될 일이 결코 없으므로 안전합니다. 따라서 mov rdx, rax 대신 lea rdx, [rax + 8]을 써도 됩니다. GC는 결코 눈치 채지 못할 겁니다!
그때까지는, return foo.Method라고 쓰는 것이 은근히 16바이트 내외의 할당을 만든다는 점에 주의하세요. (사족: 저는 예전에 구글에서 Go 팀 옆자리에 앉았었는데, Austin Clements와 이 얘기를 나눴던 기억이 납니다. 기억이 틀렸던 모양입니다. 저는 최근까지 Go가 이미 이 최적화를 하고 있다고 생각했거든요!)
여기까지 따라오셨다면, 아마 지금 기분이 이럴 겁니다:

이 글은 제 보통 글만큼 다듬어지지는 않았지만, 제가 겪어 온 것들이 꽤 쌓여서 제 스스로 참고하려고 한 번에 정리해 보았습니다.
Sizeof와 Alignof는 인트린식이므로, 컴파일러가 상수로 바꿉니다. 다만, 측정되는 타입이 제네릭이 아니어야 상수가 됩니다. 따라서 이런 식으로 함수를 한 번 감싸는 것은 제네릭 코드에서 실제로는 손해가 없습니다.↩
매우 큰 타입, 즉 abi.MaxPtrmaskBytes 크기의 배열로 포인터 워드를 기록할 수 없을 정도로 많은 워드를 가진 타입은 예외입니다. 더 큰 타입에는 GC 프로그램을 사용합니다! GC 프로그램은 대부분의 작은 타입이 쓰는 포인터 비트셋과 같은 목적을 가진, LZ로 압축된 비트셋입니다. gcprog.go를 참고하세요.
사실 reflect는 대부분의 타입에 대해 필요한 프로그램을 즉석에서 만드는 방법을 알고 있습니다! reflect/type.go를 보세요.↩