역사: Go 1.18 이전에 이 언어는 매개변수 다형성이 부족하여 개발자들이 interface{} (힙 할당 및 박스 오버헤드 발생)와 코드 생성을 선택할 수 밖에 없었습니다 (이로 인해 이진 파일 부풀림 발생). 제네릭 설계 시 Go 팀은 모든 고유 유형 인스턴스화가 중복 기계 코드를 생성하는 C++ 템플릿 모델의 완전 단형화를 명시적으로 거부했습니다. 이는 수천 개의 패키지를 연결하는 대형 클라우드 네이티브 애플리케이션에서 이진 크기 폭발에 대한 우려 때문입니다.
문제: 순수한 단형화는 Process[int] 및 **Process[uint]**에 대해 별도의 어셈블리 블록을 생성하게 되며, 이는 둘 다 64비트 정수임에도 불구하고 반영구적인 내부 캐시 및 디스크 공간을 낭비합니다. 반대로, Java와 같이 박스를 통해 제네릭을 구현하는 것은 값 유형을 힙에 올려야 하므로 Go의 시스템 프로그래밍 특유의 제로 할당 성능 특성을 파괴합니다. 문제는 컴파일 시간의 타입 안전성을 유지하면서 N배 코드 중복 문제를 피하는 것이었습니다.
해결책: Go는 GC 형태 스텐실링과 런타임 딕셔너리를 결합하여 사용합니다. 컴파일러는 정확한 유형 식별이 아닌 크기, 정렬 및 포인터 비트맵에 의해 정의되는 GC 형태로 유형을 그룹화합니다. 메모리 레이아웃이 동일한 유형 (예: []int 및 []string, 포인터, 길이 및 용량을 가진 헤더 구조체 모두)의 경우, 동일한 인스턴스화된 기계 코드 스텐실을 공유합니다. 메서드 디스패치나 타입 단언과 같은 유형별 작업을 위해, 컴파일러는 메타데이터 오프셋을 포함하는 숨겨진 런타임 딕셔너리를 전달합니다. 이는 **Point{X:1, Y:2}**와 **Vector{X:1, Y:2}**가 코드를 공유하게 하면서 값 유형은 스택에서 언박스 상태를 유지하도록 합니다.
우리는 int64 타임스탬프와 사용자 정의 Decimal128 구조체 (16바이트, 두 개의 uint64 필드)를 색인하는 제네릭 SkipList 구현이 필요한 고성능 열(columnar) 저장 엔진을 개발하고 있었습니다. **interface{}**를 이용한 초기 벤치마크에서 35%의 CPU 시간이 런타임 힙 할당과 인터페이스 간접 호출로 소비되었고, 이는 우리의 서브 마이크로초 대기 시간 요구 사항에 적합하지 않았습니다.
세 가지 아키텍처 접근법을 고려했습니다. 첫 번째는 전면적인 단형화를 통해 go generate와 text/template를 사용하여 전용 SkipListInt64 및 SkipListDecimal 구현을 생성하는 것이었습니다. 이렇게 하면 할당이 없어지지만 열두 개의 다양한 숫자 유형을 지원할 때 이진 크기가 22MB 증가하여 서버리스 배포 제약을 위반했습니다. 두 번째는 unsafe.Pointer 및 반사를 사용하여 메모리를 수동으로 관리하는 통합 구현이었습니다. 이것은 이진 크기를 최소화했지만, 테스트 중 Go의 가비지 수집기 불변성을 깨뜨리는 수동 포인터 산술을 요구하는 재앙적인 복잡성을 초래했습니다.
세 번째 접근 방식을 선택했습니다: GC 형태 그룹화를 세심하게 고려한 네이티브 Go 제네릭. 우리는 Decimal128 구조체를 [2]uint64의 메모리 레이아웃과 일치시키며, 이를 통해 다른 16바이트 값 유형과 스텐실 코드를 공유하게 하였습니다. go tool objdump를 사용해 컴파일러 출력을 분석하여 **SkipList[int64]**와 **SkipList[uint64]**가 동일한 어셈블리 블록을 공유하며, **SkipList[string]**은 포인터를 포함하는 비트맵 덕분에 올바르게 별도의 스텐실을 사용하고 있음을 확인했습니다. 이 혼합 접근 방식은 코드 생성을 통해 58% 이진 크기를 줄였고 제로 할당 성능을 유지했습니다. 결과적으로 interface{} 버전보다 4배 대기 시간 개선과 30MB 이하의 이진 크기를 달성했습니다.
왜 동일한 필드 유형을 가진 두 개의 서로 다른 구조체 유형이 때때로 별도의 제네릭 인스턴스화를 생성하는 반면, 구조체와 원시 데이터의 타입 별칭은 코드를 공유할 수 있을까요?
이는 GC 형태 그룹화가 포인터 비트맵 및 패딩을 포함한 완전한 런타임 유형 설명자에 의존하기 때문에 발생하며, 단순한 필드 유형만으로는 결정되지 않습니다. **type A struct { x, y int }**와 **type B struct { x, y int }**가 다른 패키지에서 정의된다면, 이들은 동일한 GC 형태와 스텐실을 공유합니다. 그러나 **type C struct { x *int; y int }**는 **type D struct { x, y int }**와 다른 포인터 비트맵을 가지므로 별도의 기계 코드 생성을 강제합니다. 반대로 type MyInt int와 int는 형태를 공유하지만, **struct { _ int; x int }**와 **struct { x int }**는 정렬 패딩으로 인해 다를 수 있습니다. 가비지 수집기가 모든 살아있는 변수를 위한 정확한 스택 맵을 요구하므로 레이아웃 정체성이 명명된 유형 정체성보다 중요하다는 것을 이해하는 것이 중요합니다.
제네릭 유형 매개변수에 대한 메서드 디스패치가 직접 구체 호출과 어떻게 다르며, 완전 단형화 없이는 이 오버헤드가 불가피한 이유는 무엇인가요?
제네릭 유형 매개변수 T에서 메서드를 호출할 때, 컴파일러는 직접 함수 주소가 아닌 런타임 딕셔너리를 통해 간접 호출을 생성합니다. 인터페이스 호출과는 달리, - 런타임 중 메서드를 통해 메서드를 확인하는 itab이 필요한 - 제네릭 딕셔너리 항목은 컴파일 시간에 확인되지만 숨겨진 매개변수로 전달됩니다. 이로 인해 (일반적으로 2-5 나노초) 하나의 간접 호출 수준이 도입됩니다. 후보자들은 제네릭이 수작업으로 최적화된 코드에 비해 완전히 제로 오버헤드일 것이라고 종종 가정하지만, 실제로 딕셔너리 조회는 단형화가 가능하게 할 특정 인라인 최적화를 방해하는 반면, 여전히 reflect.Value.Call보다 수배 증가로 빠릅니다.
빈 식별자 필드가 있는 제네릭 유형을 인스턴스화하는 이유가 컴파일러가 고유한 스텐실 생성을 강제하여 이진 크기를 증가시킬 수 있는 이유는 무엇입니까?
빈 필드는 공간을 차지하고 이름이 없더라도 구조체의 포인터 비트맵에 기여할 수 있으며, 이로 인해 GC 형태가 바뀔 수 있습니다. **struct { _ int64; x int64 }**는 특정 아키텍처에서 *struct { x int64 }와는 다른 크기와 정렬을 가지므로, 컴파일러는 이를 독립적인 스텐실 그룹으로 할당할 수 있습니다. 또한 빈 필드가 포인터 타입 (_ int)인 경우, 해당 타입에 대한 가비지 수집기의 추적 요구 사항이 변경되어 별도의 스택 맵이 필요합니다. 이진 크기를 최적화하려는 개발자들은 GC 형태가 패딩과 빈 필드를 포함한 전체 메모리 레이아웃에 의해 결정됨을 인식해야 합니다.