Go 在片中使用接口{}似乎会导致40倍的速度减慢。在实现基于`接口{}`的数据结构时,有没有办法绕过这个问题?

Go 在片中使用接口{}似乎会导致40倍的速度减慢。在实现基于`接口{}`的数据结构时,有没有办法绕过这个问题?,go,Go,我目前正在尝试在Go中实现一个基于树的数据结构,我在基准测试中看到了令人失望的结果。因为我试图对我接受的值进行泛化,所以我仅限于使用接口{} 所讨论的代码是一个不可变的向量trie。本质上,每当向量中的值被修改时,我都需要在trie中复制几个节点。这些节点中的每一个都实现为编译时长度已知的常量片段。例如,将一个值写入一个大的trie需要复制5个独立的32个长切片。它们必须是副本,以保持以前内容的不变性 我相信,令人失望的基准测试结果是因为我将数据存储为切片中的接口{},这些切片经常被创建、复制和

我目前正在尝试在Go中实现一个基于树的数据结构,我在基准测试中看到了令人失望的结果。因为我试图对我接受的值进行泛化,所以我仅限于使用接口{}

所讨论的代码是一个不可变的向量trie。本质上,每当向量中的值被修改时,我都需要在trie中复制几个节点。这些节点中的每一个都实现为编译时长度已知的常量片段。例如,将一个值写入一个大的trie需要复制5个独立的32个长切片。它们必须是副本,以保持以前内容的不变性

我相信,令人失望的基准测试结果是因为我将数据存储为切片中的接口{},这些切片经常被创建、复制和附加。为了衡量这一点,我建立了以下基准

package main

import (
    "math/rand"
    "testing"
)

func BenchmarkMake10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        _ = make([]int, 10e6, 10e6)
    }
}

func BenchmarkMakePtr10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        _ = make([]*int, 10e6, 10e6)
    }
}

func BenchmarkMakeInterface10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        _ = make([]interface{}, 10e6, 10e6)
    }
}

func BenchmarkMakeInterfacePtr10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        _ = make([]interface{}, 10e6, 10e6)
    }
}
func BenchmarkAppend10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        slc := make([]int, 0, 0)
        for jj := 0; jj < 10e6; jj++ {
            slc = append(slc, jj)
        }
    }
}

func BenchmarkAppendPtr10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        slc := make([]*int, 0, 0)
        for jj := 0; jj < 10e6; jj++ {
            slc = append(slc, &jj)
        }
    }
}

func BenchmarkAppendInterface10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        slc := make([]interface{}, 0, 0)
        for jj := 0; jj < 10e6; jj++ {
            slc = append(slc, jj)
        }
    }
}

func BenchmarkAppendInterfacePtr10M(b *testing.B) {
    for ii := 0; ii < b.N; ii++ {
        slc := make([]interface{}, 0, 0)
        for jj := 0; jj < 10e6; jj++ {
            slc = append(slc, &jj)
        }
    }
}

func BenchmarkSet(b *testing.B) {
    slc := make([]int, 10e6, 10e6)
    b.ResetTimer()
    for ii := 0; ii < b.N; ii++ {
        slc[rand.Intn(10e6-1)] = 1
    }
}

func BenchmarkSetPtr(b *testing.B) {
    slc := make([]*int, 10e6, 10e6)
    b.ResetTimer()
    for ii := 0; ii < b.N; ii++ {
        theInt := 1
        slc[rand.Intn(10e6-1)] = &theInt
    }
}

func BenchmarkSetInterface(b *testing.B) {
    slc := make([]interface{}, 10e6, 10e6)
    b.ResetTimer()
    for ii := 0; ii < b.N; ii++ {
        slc[rand.Intn(10e6-1)] = 1
    }
}

func BenchmarkSetInterfacePtr(b *testing.B) {
    slc := make([]interface{}, 10e6, 10e6)
    b.ResetTimer()
    for ii := 0; ii < b.N; ii++ {
        theInt := 1
        slc[rand.Intn(10e6-1)] = &theInt
    }
}
其中,Set和Make的差异似乎约为2-4x,而Append的差异约为40x

据我所知,性能受到影响是因为幕后接口被实现为指针,而指针必须在堆上分配。这仍然不能解释为什么Append比Set和Make之间的差异更糟糕


在当前的Go语言中,是否有一种方法不使用代码生成工具(例如,泛型工具),让库的使用者生成库的版本以存储FooType,从而解决这一40倍的性能问题?或者,我在基准测试中是否犯了一些错误?

让我们用内存基准来分析测试

go test -bench . -cpuprofile cpu.prof -benchmem
goos: linux
goarch: amd64
BenchmarkMake10M-8                           100          10254248 ns/op        80003282 B/op          1 allocs/op
BenchmarkMakePtr10M-8                        100          18696295 ns/op        80003134 B/op          1 allocs/op
BenchmarkMakeInterface10M-8                   50          34501361 ns/op        160006147 B/op         1 allocs/op
BenchmarkMakeInterfacePtr10M-8                50          35129085 ns/op        160006652 B/op         1 allocs/op
BenchmarkAppend10M-8                          20          69971722 ns/op        423503264 B/op        50 allocs/op
BenchmarkAppendPtr10M-8                        1        2135090501 ns/op        423531096 B/op        62 allocs/op
BenchmarkAppendInterface10M-8                  1        1833396620 ns/op        907567984 B/op  10000060 allocs/op
BenchmarkAppendInterfacePtr10M-8               1        2270970241 ns/op        827546240 B/op        53 allocs/op
BenchmarkSet-8                          30000000                54.0 ns/op             0 B/op          0 allocs/op
BenchmarkSetPtr-8                       20000000                91.6 ns/op             8 B/op          1 allocs/op
BenchmarkSetInterface-8                 30000000                58.0 ns/op             0 B/op          0 allocs/op
BenchmarkSetInterfacePtr-8              20000000                88.0 ns/op             8 B/op          1 allocs/op
PASS
ok      _/home/grzesiek/test    22.427s
我们可以看到,最慢的基准是进行分配的基准

PPROF_BINARY_PATH=. go tool pprof -disasm BenchmarkAppend cpu.prof
Total: 29.75s
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppend10M
     210m   1.51s (flat, cum)  5.08% of Total
     .      1.30s     4e827a: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppend10M test_test.go:35
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterface10M
     20m    930ms (flat, cum)  3.13% of Total
     .      630ms     4e8519: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppendInterface10M test_test.go:53
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterfacePtr10M
     0      800ms (flat, cum)  2.69% of Total
     .      770ms     4e8625: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppendInterfacePtr10M test_test.go:62
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendPtr10M
     0      950ms (flat, cum)  3.19% of Total
     .      870ms     4e8374: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppendPtr10M test_test.go:44
通过分析分配的字节数,我们可以看到使用接口将分配大小增加了一倍

为什么BenchmarkAppendPtr10M比其他BenchmarkAppend*快这么多? 要想弄明白这一点,我们需要看看逃逸分析

go test -gcflags '-m -l' original_test.go

./original_test.go:31:28: BenchmarkAppend10M b does not escape                      
./original_test.go:33:14: BenchmarkAppend10M make([]int, 0, 0) does not escape      
./original_test.go:40:31: BenchmarkAppendPtr10M b does not escape                   
./original_test.go:42:14: BenchmarkAppendPtr10M make([]*int, 0, 0) does not escape  
./original_test.go:43:7: moved to heap: jj                                          
./original_test.go:44:22: &jj escapes to heap                                       
./original_test.go:49:37: BenchmarkAppendInterface10M b does not escape             
./original_test.go:51:14: BenchmarkAppendInterface10M make([]interface {}, 0, 0) does not escape                                                                         
./original_test.go:53:16: jj escapes to heap                                        
./original_test.go:58:40: BenchmarkAppendInterfacePtr10M b does not escape          
./original_test.go:60:14: BenchmarkAppendInterfacePtr10M make([]interface {}, 0, 0) does not escape                                                                      
./original_test.go:61:7: moved to heap: jj                                          
./original_test.go:62:16: &jj escapes to heap                                       
./original_test.go:62:22: &jj escapes to heap                                       
我们可以看到,它是jj没有逃逸到堆中的唯一基准。我们可以推断,访问heap变量会导致速度减慢

为什么Benchmarke10M会进行如此多的分配? 在汇编器中,我们可以看到它是唯一调用runtime.convT2E64函数的汇编器

PPROF_BINARY_PATH=. go tool pprof -disasm BenchmarkAppend cpu.prof

ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterface10M
      30ms      1.10s (flat, cum)  3.35% of Total
         .      260ms     4e8490: CALL runtime.convT2E64(SB)                 
运行时/iface.go中的源代码如下所示:

func convT2E64(t *_type, elem unsafe.Pointer) (e eface) {
    if raceenabled {
        raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2E64))
    }
    if msanenabled {
        msanread(elem, t.size)
    }
    var x unsafe.Pointer
    if *(*uint64)(elem) == 0 {
        x = unsafe.Pointer(&zeroVal[0])
    } else {
        x = mallocgc(8, t, false)
        *(*uint64)(x) = *(*uint64)(elem)
    }
    e._type = t
    e.data = x
    return
}
正如我们看到的,它通过调用mallocgc函数进行分配

PPROF_BINARY_PATH=. go tool pprof -disasm BenchmarkAppend cpu.prof

ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterface10M
      30ms      1.10s (flat, cum)  3.35% of Total
         .      260ms     4e8490: CALL runtime.convT2E64(SB)                 

我知道它不能直接帮助修复您的代码,但我希望它能为您提供分析和优化代码的工具和技术。

让我们用内存基准评测测试

go test -bench . -cpuprofile cpu.prof -benchmem
goos: linux
goarch: amd64
BenchmarkMake10M-8                           100          10254248 ns/op        80003282 B/op          1 allocs/op
BenchmarkMakePtr10M-8                        100          18696295 ns/op        80003134 B/op          1 allocs/op
BenchmarkMakeInterface10M-8                   50          34501361 ns/op        160006147 B/op         1 allocs/op
BenchmarkMakeInterfacePtr10M-8                50          35129085 ns/op        160006652 B/op         1 allocs/op
BenchmarkAppend10M-8                          20          69971722 ns/op        423503264 B/op        50 allocs/op
BenchmarkAppendPtr10M-8                        1        2135090501 ns/op        423531096 B/op        62 allocs/op
BenchmarkAppendInterface10M-8                  1        1833396620 ns/op        907567984 B/op  10000060 allocs/op
BenchmarkAppendInterfacePtr10M-8               1        2270970241 ns/op        827546240 B/op        53 allocs/op
BenchmarkSet-8                          30000000                54.0 ns/op             0 B/op          0 allocs/op
BenchmarkSetPtr-8                       20000000                91.6 ns/op             8 B/op          1 allocs/op
BenchmarkSetInterface-8                 30000000                58.0 ns/op             0 B/op          0 allocs/op
BenchmarkSetInterfacePtr-8              20000000                88.0 ns/op             8 B/op          1 allocs/op
PASS
ok      _/home/grzesiek/test    22.427s
我们可以看到,最慢的基准是进行分配的基准

PPROF_BINARY_PATH=. go tool pprof -disasm BenchmarkAppend cpu.prof
Total: 29.75s
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppend10M
     210m   1.51s (flat, cum)  5.08% of Total
     .      1.30s     4e827a: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppend10M test_test.go:35
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterface10M
     20m    930ms (flat, cum)  3.13% of Total
     .      630ms     4e8519: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppendInterface10M test_test.go:53
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterfacePtr10M
     0      800ms (flat, cum)  2.69% of Total
     .      770ms     4e8625: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppendInterfacePtr10M test_test.go:62
ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendPtr10M
     0      950ms (flat, cum)  3.19% of Total
     .      870ms     4e8374: CALL runtime.growslice(SB)                 ;_/home/grzesiek/test.BenchmarkAppendPtr10M test_test.go:44
通过分析分配的字节数,我们可以看到使用接口将分配大小增加了一倍

为什么BenchmarkAppendPtr10M比其他BenchmarkAppend*快这么多? 要想弄明白这一点,我们需要看看逃逸分析

go test -gcflags '-m -l' original_test.go

./original_test.go:31:28: BenchmarkAppend10M b does not escape                      
./original_test.go:33:14: BenchmarkAppend10M make([]int, 0, 0) does not escape      
./original_test.go:40:31: BenchmarkAppendPtr10M b does not escape                   
./original_test.go:42:14: BenchmarkAppendPtr10M make([]*int, 0, 0) does not escape  
./original_test.go:43:7: moved to heap: jj                                          
./original_test.go:44:22: &jj escapes to heap                                       
./original_test.go:49:37: BenchmarkAppendInterface10M b does not escape             
./original_test.go:51:14: BenchmarkAppendInterface10M make([]interface {}, 0, 0) does not escape                                                                         
./original_test.go:53:16: jj escapes to heap                                        
./original_test.go:58:40: BenchmarkAppendInterfacePtr10M b does not escape          
./original_test.go:60:14: BenchmarkAppendInterfacePtr10M make([]interface {}, 0, 0) does not escape                                                                      
./original_test.go:61:7: moved to heap: jj                                          
./original_test.go:62:16: &jj escapes to heap                                       
./original_test.go:62:22: &jj escapes to heap                                       
我们可以看到,它是jj没有逃逸到堆中的唯一基准。我们可以推断,访问heap变量会导致速度减慢

为什么Benchmarke10M会进行如此多的分配? 在汇编器中,我们可以看到它是唯一调用runtime.convT2E64函数的汇编器

PPROF_BINARY_PATH=. go tool pprof -disasm BenchmarkAppend cpu.prof

ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterface10M
      30ms      1.10s (flat, cum)  3.35% of Total
         .      260ms     4e8490: CALL runtime.convT2E64(SB)                 
运行时/iface.go中的源代码如下所示:

func convT2E64(t *_type, elem unsafe.Pointer) (e eface) {
    if raceenabled {
        raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2E64))
    }
    if msanenabled {
        msanread(elem, t.size)
    }
    var x unsafe.Pointer
    if *(*uint64)(elem) == 0 {
        x = unsafe.Pointer(&zeroVal[0])
    } else {
        x = mallocgc(8, t, false)
        *(*uint64)(x) = *(*uint64)(elem)
    }
    e._type = t
    e.data = x
    return
}
正如我们看到的,它通过调用mallocgc函数进行分配

PPROF_BINARY_PATH=. go tool pprof -disasm BenchmarkAppend cpu.prof

ROUTINE ======================== _/home/grzesiek/test.BenchmarkAppendInterface10M
      30ms      1.10s (flat, cum)  3.35% of Total
         .      260ms     4e8490: CALL runtime.convT2E64(SB)                 

我知道它不能直接帮助您修复代码,但我希望它能为您提供分析和优化代码的工具和技术。

我看到的明显问题是您正在制作容量为0的切片。请尝试一个更现实的数字。@MichaelHampton我想看看在最坏的情况下append的性能是什么。我知道,如果我将上限设置为10e6,它将立即分配整个内容,并且任何其他附件基本上都是在引擎盖下设置的。添加Set和Make结果应该会提供最佳的case-Append性能。我的实际实现涉及大量分配len=constant的新切片,将旧切片数据复制到其中,我觉得这相当于append在最坏情况下的功能,但我问了我关于append的问题,使其更一般。显示真实代码和真实问题。基准代码看起来非常不自然。对于corse,您将不得不为使用空接口付出沉重的代价,但您的实际问题可能会在一个稍大的接口上得到解决?如果您一直分配新的片,无论是显式地使用make还是隐式地使用append,性能受损我并不感到奇怪。看看是否可以重新使用切片。通过重新切片[:0],可以免费重置它们。例如,您可以使用sync.Pool,或者实现自己的简单环形缓冲区,或者为不同的片容量使用多个池。如果不看代码,很难判断什么是有意义的。请注意,接口{}中包装了什么值确实很重要,
因为这将是一个副本,在大结构的情况下,这将花费你。。。在这种情况下,您应该只存储换行指针。。。这个没有相关代码的问题是离题的。我看到的明显问题是,您正在制作容量为0的切片。请尝试一个更现实的数字。@MichaelHampton我想看看在最坏的情况下append的性能是什么。我知道,如果我将上限设置为10e6,它将立即分配整个内容,并且任何其他附件基本上都是在引擎盖下设置的。添加Set和Make结果应该会提供最佳的case-Append性能。我的实际实现涉及大量分配len=constant的新切片,将旧切片数据复制到其中,我觉得这相当于append在最坏情况下的功能,但我问了我关于append的问题,使其更一般。显示真实代码和真实问题。基准代码看起来非常不自然。对于corse,您将不得不为使用空接口付出沉重的代价,但您的实际问题可能会在一个稍大的接口上得到解决?如果您一直分配新的片,无论是显式地使用make还是隐式地使用append,性能受损我并不感到奇怪。看看是否可以重新使用切片。通过重新切片[:0],可以免费重置它们。例如,您可以使用sync.Pool,或者实现自己的简单环形缓冲区,或者为不同的片容量使用多个池。如果不看代码,很难判断什么是有意义的。请注意,接口{}中包装了什么值很重要,因为这将是一个副本,如果是大型结构,这将花费您。。。在这种情况下,您应该只存储换行指针。。。此问题没有相关代码,属于离题。谢谢您的回答。您已经向我展示了避免这种减速的唯一方法是重新设计我的解决方案,以避免接口切片,以及代码对堆/堆栈差异的敏感性。我对BenchmarkAppendInterface10M和BenchmarkAppendInterfacePtr10M之间allocs/op的差异很感兴趣。谢谢您的回答。您已经向我展示了避免这种减速的唯一方法是重新设计我的解决方案,以避免接口切片,以及代码对堆/堆栈差异的敏感性。我对BenchmarkAppendInterface10M和BenchmarkAppendInterfacePtr10M之间的allocs/op差异很感兴趣。