Go Reflect: Type Optimization

Explore the performance and functionality

Go Reflect: Type Optimization

Intro

想象下面一个场景:

​有一类定义差异很大的struct:

type subStruct struct {
	SubFieldOne string
	SubFieldTwo int
}

type a struct {
	FieldOne   string
	FieldTwo   int
	FieldThree float64
	FieldFour  map[string]string
	FieldFive  subStruct
}

type b struct {
	FieldOne   int
	FieldTwo   string
	FieldThree map[string]string
	FieldFour  float64
	FieldFive  subStruct
}

我们希望将这两种(以及更多种)struct都能够转换成一个「以field和value为单位」的数据结构;类似于pb/json的序列化,我们希望能将其转换成一个统一的结构,便于我们对这一类结构做一些统一的工作:

  • 针对字段的校验、标准化修改
  • 用于生成格式一致的Record,服务于打点或某些格式的归一化

在Golang中,提供了通过反射来访问field、value的机制,允许我们在运行时得到field和value的值,来完成上述目标。

Basic

下面我们定义一个示例的归一化数据结构:

type NomarlizedElement struct {
	Key   string
	Value interface{}
}

type NormalizedType []*NomarlizedElement

该结构表示了一个切片,其中每个元素为一个包含Key Value的struct,其值分别应当来自原始结构体的Field和Value。

通过递归的解析,我们很容易写出这样的一个转换函数:

func transStruct(input interface{}) (n normalizedType) {
	n = make(normalizedType, 0)
	t := reflect.TypeOf(input)
	v := reflect.ValueOf(input)
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
		v = v.Elem()
	}
	if t.Kind() != reflect.Struct {
		return
	}
	numField := t.NumField()
	for i := 0; i < numField; i++ {
		field := t.Field(i)
		if field.Type.Kind() == reflect.Struct {
			n = append(n, transStruct(v.Field(i).Interface())...)
		} else {
			n = append(n, &nomarlizedElement{field.Name, v.Field(i)})
		}
	}
	return
}

函数接受一个Interface入参,其类型必须是struct或指向struct的指针。在递归逻辑中,每一次调用判断传入值的反射类型和反射值,并遍历反射获得的Field,逐个append到返回值中,并通过递归不断向上传递,最终返回值为所有值的归一化切片。

func main() {
	input := &a{
		FieldOne:   "1",
		FieldTwo:   2,
		FieldThree: 3.0,
		FieldFour: map[string]string{
			"1": "1",
			"2": "2",
		},
		FieldFive: struct {
			SubFieldOne string
			SubFieldTwo int
		}{
			SubFieldOne: "1",
			SubFieldTwo: 2,
		},
	}

	for _, v := range transStruct(input) {
		fmt.Println(v.Key, v.Value)
	}
    // output: 
      FieldOne 1
      FieldTwo 2
      FieldThree 3
      FieldFour map[1:1 2:2]
      SubFieldOne 1
      SubFieldTwo 2
}

这个函数的实现非常简单,但我们很容易观察到一些问题:

  • 我们没有对Value的类型做任何判断,而只是将其塞进Interface中,这样的“通用数据结构”其实还伴随着要不断做类型断言的代价
  • 大量反射的使用,导致这个函数的性能非常差

针对第二个问题,我们可以简单的编写一个Benchmark来辅助我们量化性能劣化的程度:

func Benchmark_transStruct(b *testing.B) {
	b.ResetTimer()
	b.ReportAllocs()
	input := &a{
		FieldOne:   "1",
		FieldTwo:   2,
		FieldThree: 3.0,
		FieldFour: map[string]string{
			"1": "1",
			"2": "2",
		},
		FieldFive: struct {
			SubFieldOne string
			SubFieldTwo int
		}{
			SubFieldOne: "1",
			SubFieldTwo: 2,
		},
	}
	for i := 0; i < b.N; i++ {
		transStruct(input)
	}
}

func Benchmark_Compare(b *testing.B) {
	b.ResetTimer()
	b.ReportAllocs()
	input := &a{
		FieldOne:   "1",
		FieldTwo:   2,
		FieldThree: 3.0,
		FieldFour: map[string]string{
			"1": "1",
			"2": "2",
		},
		FieldFive: struct {
			SubFieldOne string
			SubFieldTwo int
		}{
			SubFieldOne: "1",
			SubFieldTwo: 2,
		},
	}
	for i := 0; i < b.N; i++ {
		a := make(normalizedType, 0)
		a = append(a, &nomarlizedElement{"FieldOne", input.FieldOne})
		a = append(a, &nomarlizedElement{"FieldTwo", input.FieldTwo})
		a = append(a, &nomarlizedElement{"FieldThree", input.FieldThree})
		a = append(a, &nomarlizedElement{"FieldFour", input.FieldFour})
		a = append(a, &nomarlizedElement{"SubFieldOne", input.FieldFive.SubFieldOne})
		a = append(a, &nomarlizedElement{"SubFieldTwo", input.FieldFive.SubFieldTwo})
	}
}
goos: linux
goarch: amd64
pkg: code.byted.org/ttarch/insight_service/myfunc
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
Benchmark_transStruct-8           628802              1958 ns/op             560 B/op         26 allocs/op
Benchmark_Compare-8              1490454               795.8 ns/op           352 B/op         13 allocs/op
PASS

作为对照的Compare组的耗时、内存分配均不到测试函数的一半;如果结构体的字段变多、嵌套结构更加复杂,性能的劣化可能还会继续加强。

Optimization

Performance

上面提及的问题中,目标类型的功能性问题还可以通过workaround来解决,而性能劣化的问题通常很难再后续部分中解决;所以我们首先可能需要考量的是性能问题。

众所周知,Golang的运行时反射性能很差,我们粗略的静态观察代码也容易发现,代码涉及的调用并不多,除了几个常规的内存操作,就只剩下反射的调用可能带来这个耗时影响;

但我们仍然不能明确的得知这些多样的反射函数的耗时分布,所以我们可以通过一些工具来辅助判断:

这里我们使用应用广泛的pprof来做Profile:

var tmp interface{} // avoid compiler optimization

func main() {
	f, _ := os.Create("cpu.profile")
	pprof.StartCPUProfile(f)
	defer pprof.StopCPUProfile()
	input := &a{
		FieldOne:   "1",
		FieldTwo:   2,
		FieldThree: 3.0,
		FieldFour: map[string]string{
			"1": "1",
			"2": "2",
		},
		FieldFive: struct {
			SubFieldOne string
			SubFieldTwo int
		}{
			SubFieldOne: "1",
			SubFieldTwo: 2,
		},
	}
	for i := 0; i < 100000; i++ {
		tmp = transStruct(input)
	}
}

从火焰图中可以观察到,reflect.Type.Field的开销占比非常大,剩余的便是slice的分配等错误。

Golang的运行时反射中,Value和Type是分离的;Value完全和变量本身绑定,没有可直接复用的结构;Type是类型的静态信息,相同的类型实际上共享同一个Type结构。

我们可以利用上面的这个机制,缓存Type所包含的字段,来帮助我们优化调用开销:

首先,我们确定我们所需缓存的信息:

  • fieldName:字段名
  • fieldType:递归时需要判断
  • fieldKind:递归时需要判断

可以定义这样一个结构:

type field struct {
	name      string
	typ       reflect.Type
	kind      reflect.Kind
}

type fieldCache []*field

考虑到并发安全,且场景为多读少写,我们使用sync.Map来存储这一缓存:

var typeCache = &sync.Map{}

func getTypes(t reflect.Type) fieldCache {
	if t.Kind() == reflect.Pointer {
		t = t.Elem()
	}
	fCache := make(fieldCache, 0)
	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		kind := f.Type.Kind()
		fCache = append(fCache, &field{f.Name, f.Type, kind})
	}
	return fCache
}

func cacheType(t reflect.Type) fieldCache {
	if r, ok := typeCache.Load(t); ok {
		return r.(fieldCache)
	}
	r, _ := typeCache.LoadOrStore(t, getTypes(t))
	return r.(fieldCache)
}

通过这样的改造,我们改写transStruct代码中的Type.Kind逻辑:

func transStruct(input interface{}) (n normalizedType) {
	n = make(normalizedType, 0)
	t := reflect.TypeOf(input)
	v := reflect.ValueOf(input)
	kind := t.Kind()
	if kind == reflect.Ptr {
		t = t.Elem()
		v = v.Elem()
        kind = t.Kind()
	}
	if kind != reflect.Struct {
		return
	}
	tCache := cacheType(t)

	for index, f := range tCache {
		if f.kind == reflect.Struct {
			n = append(n, transStruct(v.Field(index).Interface())...)
		} else {
			n = append(n, &nomarlizedElement{f.name, v.Field(index)})
		}
	}
	return
}

修改后,再次进行Benchmark:

goos: linux
goarch: amd64
pkg: code.byted.org/ttarch/insight_service/myfunc
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
Benchmark_transStruct-8   	  889690	      1519 ns/op	     504 B/op	      19 allocs/op
PASS

从1900ns+降低到了1500ns+,效果还是比较明显的。

但这样就够了吗?我们是不是还有优化的空间?

再次进行pprof:

我们可以看到,cache的引入使得均摊的Type反射成本明显得到了降低,而切片的Grow和malloc占据了较大的耗时比例。

我们可以遵循经典的优化思路,尽可能预分配切片来减少内存分配的开销:

很自然的,我们可以使用这样的方式来预分配切片:

n = make(normalizedType, len(tCache))

但这样仍然会存在一个问题,当存在嵌套struct时,我们并不便于确定子项的field数量,导致我们要么不预分配切片,要么只计算非struct的field,对嵌套的struct再使用append触发扩容。

对第二种情况,我们可以这样实现:

func getTypes(t reflect.Type) *fieldCache {
	if t.Kind() == reflect.Pointer {
		t = t.Elem()
	}
	fCache := &fieldCache{
		numFields: 0,
		fs:        []*field{},
	}
	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		kind := f.Type.Kind()
		if kind != reflect.Struct { // 跳过嵌套struct
			fCache.numFields++
		}
		fCache.fs = append(fCache.fs, &field{f.Name, f.Type, kind})
	}
	return fCache
}

func transStruct(input interface{}) (n normalizedType) {
	t := reflect.TypeOf(input)
	v := reflect.ValueOf(input)
	kind := t.Kind()
	if kind == reflect.Ptr {
		t = t.Elem()
		v = v.Elem()
		kind = t.Kind()
	}
	if kind != reflect.Struct {
		return
	}
	tCache := cacheType(t)

	n = make(normalizedType, tCache.numFields)
	offset := 0
	for index, f := range tCache.fs {
		if f.kind == reflect.Struct {
			n = append(n, transStruct(v.Field(index).Interface())...)
		} else {
			n[offset] = &nomarlizedElement{f.name, v.Field(index).Interface()}
			offset++ // 计算num时忽略了struct,offset是不包含struct的实际index
		}
	}
	return
}

改造后,我们再进行benchmark:

goos: linux
goarch: amd64
pkg: code.byted.org/ttarch/insight_service/myfunc
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
Benchmark_transStruct-8   	 1202935	      1042 ns/op	     360 B/op	      13 allocs/op
PASS

从1500ns+降低到1000ns左右,非常接近基准性能的800ns,性能的改善已经非常可观了。

但这个实现仍然存在一个显而易见的问题:嵌套的struct一定会出现在末尾,而不是定义顺序。

要保持顺序的话,我们需要在根struct就提前判断数组的总长度,并在递归获取值时不断修正offset,稍显复杂,但也可以像下面这样实现:

func getTypes(t reflect.Type) *fieldCache {
	if t.Kind() == reflect.Pointer {
		t = t.Elem()
	}
	fCache := &fieldCache{
		numFields: 0,
		fs:        []*field{},
	}
	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		kind := f.Type.Kind()

		if kind == reflect.Struct {
			fCache.numFields += cacheType(f.Type).numFields
		} else {
			fCache.numFields++
		}
		fCache.fs = append(fCache.fs, &field{f.Name, f.Type, kind})
	}
	re

func transStruct(input interface{}) (n normalizedType) {
	t := reflect.TypeOf(input)
	v := reflect.ValueOf(input)
	kind := t.Kind()
	if kind == reflect.Ptr {
		t = t.Elem()
		v = v.Elem()
		kind = t.Kind()
	}
	if kind != reflect.Struct {
		return
	}
	tCache := cacheType(t)

	n = make(normalizedType, tCache.numFields)
	offset := 0
	for index, f := range tCache.fs {
		if f.kind == reflect.Struct {
			res := transStruct(v.Field(index).Interface())
			for i, item := range res {
				n[i+offset] = item
			}
			offset += len(res)
		} else {
			n[offset] = &nomarlizedElement{f.name, v.Field(index).Interface()}
			offset++
		}
	}
	return
}

这样,我们便不必承受append带来的扩容开销,尽可能缩小到每次递归的创建开销,Benchmark:

goos: linux
goarch: amd64
pkg: code.byted.org/ttarch/insight_service/myfunc
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
Benchmark_transStruct-8   	 1286822	       951.4 ns/op	     312 B/op	      12 allocs/op

性能甚至有一些微乎其微的提升,我们也保留了顺序。

但其实这里的性能对比是不公平的,我们所对比的基准测试并非一次性分配内存的版本,我们可以重新编写一版基准测试的代码:

func Benchmark_Compare(b *testing.B) {
	b.ResetTimer()
	b.ReportAllocs()
	input := &a{
		FieldOne:   "1",
		FieldTwo:   2,
		FieldThree: 3.0,
		FieldFour: map[string]string{
			"1": "1",
			"2": "2",
		},
		FieldFive: struct {
			SubFieldOne string
			SubFieldTwo int
		}{
			SubFieldOne: "1",
			SubFieldTwo: 2,
		},
	}
	for i := 0; i < b.N; i++ {
		a := make(normalizedType, 6)
		a[0] = &nomarlizedElement{"FieldOne", input.FieldOne}
		a[1] = &nomarlizedElement{"FieldTwo", input.FieldTwo}
		a[2] = &nomarlizedElement{"FieldThree", input.FieldThree}
		a[3] = &nomarlizedElement{"FieldFour", input.FieldFour}
		a[4] = &nomarlizedElement{"SubFieldOne", input.FieldFive.SubFieldOne}
		a[5] = &nomarlizedElement{"SubFieldTwo", input.FieldFive.SubFieldTwo}
	}
}

benchmark结果:

goos: linux
goarch: amd64
pkg: code.byted.org/ttarch/insight_service/myfunc
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
Benchmark_Compare-8   	 3019230	       470.4 ns/op	     232 B/op	       9 allocs/op
PASS

我们可以看到,优化后耗时仍然是基准的一倍左右,但考虑到基准代码很难比较是否有嵌套结构体的开销,这个性能的劣化是完全可以接受的。

Functional

前文中提到,我们直接存入一个string interface的结构体的方式实际上很难满足通用场景的需要,我们可以在现有的代码基础上,来支持一些自定义的类型:

例如我们将Value替换为String,代码可以像这样实现:

type subStruct struct {
	SubFieldOne string
	SubFieldTwo int
}

type cache struct {
	m map[reflect.Type]*fieldInfos
	*sync.RWMutex
}

var fc = cache{
	make(map[reflect.Type]*fieldInfos),
	&sync.RWMutex{},
}

type a struct {
	FieldOne   string
	FieldTwo   int
	FieldThree float64
	FieldFour  map[string]string
	FieldFive  *subStruct
}

type b struct {
	FieldOne   int
	FieldTwo   string
	FieldThree map[string]string
	FieldFour  float64
	FieldFive  subStruct
}

type nomarlizedElement struct {
	Key   string
	Value string
}

var typeCache = &sync.Map{}

type fieldInfo struct {
	name      string
	typ       reflect.Type
	kind      reflect.Kind
	transFunc func(v reflect.Value) string // 转为string的函数
}

type fieldInfos struct {
	numField int
	fs       []*fieldInfo
}

type normalizedType []*nomarlizedElement

func getTypes(t reflect.Type) *fieldInfos {
	if t.Kind() == reflect.Pointer {
		t = t.Elem()
	}
	numFields := t.NumField() // 记录单层的字段数量
	fieldsInfo := &fieldInfos{
		numField: 0, // 单层数量不能表示子结构体,需要递归计算,先置为0
		fs:       make([]*fieldInfo, numFields),
	}

	for i := 0; i < numFields; i++ {
		f := t.Field(i)
		kind := f.Type.Kind()
		if kind == reflect.Pointer {
			kind = f.Type.Elem().Kind()
		}
		field := &fieldInfo{
			name: f.Name,
			typ:  f.Type,
			kind: kind,
		}

		switch field.kind {
		case reflect.String:
			fieldsInfo.numField++
			field.transFunc = valueString2String // help compiler inline
		case reflect.Int8, reflect.Int, reflect.Int16, reflect.Int32:
			fieldsInfo.numField++
			field.transFunc = valueInts2String // help compiler inline
		case reflect.Float32, reflect.Float64:
			fieldsInfo.numField++
			field.transFunc = valueFloats2String // help compiler inline
		case reflect.Map:
			fieldsInfo.numField++
			field.transFunc = valueMap2String
		case reflect.Struct:
			fieldsInfo.numField += cacheType(field.typ).numField
		default:
			continue
		}

		fieldsInfo.fs[i] = field
	}
	return fieldsInfo
}

func cacheType(t reflect.Type) *fieldInfos {
	if r, ok := fc.m[t]; ok {
		return r
	}
	res := getTypes(t)
	if got := fc.TryLock(); got {
		defer fc.Unlock()
		fc.m[t] = res
	}
	return res
}

func transStruct(input interface{}) (n normalizedType) {
	t := reflect.TypeOf(input)
	v := reflect.ValueOf(input)
	kind := t.Kind()
	if kind == reflect.Ptr {
		t = t.Elem()
		v = v.Elem()
		kind = t.Kind()
	}
	if kind != reflect.Struct {
		return
	}
	tCache := cacheType(t)

	n = make(normalizedType, tCache.numField)
	offset := 0
	for index, f := range tCache.fs {
		if f.kind == reflect.Struct {
			vi := v.Field(index).Interface()
			res := transStruct(vi)
			for i, item := range res {
				n[i+offset] = item
			}
			offset += len(res)
		} else {
			n[offset] = &nomarlizedElement{f.name, f.transFunc(v.Field(index))}
			offset++
		}
	}
	return
}

func valueString2String(v reflect.Value) string {
	return v.String()
}

func valueInts2String(v reflect.Value) string {
	return strconv.FormatInt(v.Int(), 10)
}

func valueFloats2String(v reflect.Value) string {
	return strconv.FormatFloat(v.Float(), 'f', -1, 64)
}

func valueMap2String(v reflect.Value) string {
	s, _ := sonic.MarshalString(v.Interface())
	return s
}
示例的代码中使用了多种手段来保证性能,例如将函数独立出来帮助编译期内联;自行实现带读锁的map来降低类型断言的开销;预分配type缓存...

在添加对应的类型转换函数(必不可省的开销)后,再次编写一个测试对比函数:

func Benchmark_transStruct(b *testing.B) {
	b.ResetTimer()
	b.ReportAllocs()
	input := &a{
		FieldOne:   "1",
		FieldTwo:   2,
		FieldThree: 3.0,
		FieldFour: map[string]string{
			"1": "1",
			"2": "2",
		},
		FieldFive: &subStruct{
			SubFieldOne: "1",
			SubFieldTwo: 2,
		},
	}
	b.Run("CustomTrans",
		func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				transStruct(input)
			}
		},
	)
	b.Run("RawBuild",
		func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				a := make(normalizedType, 6)
				a[0] = &nomarlizedElement{"FieldOne", input.FieldOne}
				a[1] = &nomarlizedElement{"FieldTwo", strconv.Itoa(input.FieldTwo)}
				a[2] = &nomarlizedElement{"FieldThree", strconv.FormatFloat(input.FieldThree, 'f', -1, 64)}
				s, _ := sonic.MarshalString(&input.FieldFour)
				a[3] = &nomarlizedElement{"FieldFour", s}
				a[4] = &nomarlizedElement{"SubFieldOne", input.FieldFive.SubFieldOne}
				a[5] = &nomarlizedElement{"SubFieldTwo", strconv.Itoa(input.FieldFive.SubFieldTwo)}
			}
		},
	)
}

结果如下:

goos: linux
goarch: amd64
pkg: code.byted.org/ttarch/insight_service/myfunc
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
Benchmark_transStruct/CustomTrans-8         	  843984	      1514 ns/op	     407 B/op	      13 allocs/op
Benchmark_transStruct/RawBuild-8            	 1000000	      1126 ns/op	     334 B/op	      11 allocs/op
PASS

可以看到,转换为最终结构的耗时的差异比例已经比较小了。

到此,我们已经可以认为在这个细分的用途下,函数的开销已经几乎达到了极限;再进一步的优化可能需要考虑通过一些不安全的指针操作来节省某些开销,但这样需要投入更精细的调整来实现,且很有可能不适合生产用途,本文就不再探讨。

对于更多类型的支持,可以尝试用泛型来减少开发的复杂度,但不可避免的,我们仍然必须实现每个类型的转换方法,而且必须注意的是,与String类型不同的是,我们没有办法将所有的格式都转换到其他格式中去,所以本文不再对这些特殊情况进行额外探讨,有兴趣的读者可以尝试自行实现对应的方法。

Summary

对于运行时反射的性能探索可以让我们更清晰的认识到我们平时写下的一些看似『便利』的代码背后可能带来的额外开销;这样的优化行为本身可能并不意味着一个最佳的结果,但我们在尝试优化的过程中对各种信息的收集、分析和总结的方法,都会为未来更复杂情形下的性能优化提供宝贵的经验。