Go performance from version 1.0 to 1.22

Ben Hoyt

Go 1.0부터 1.22까지의 성능

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

2년 전 저는 비교한 바 있습니다. 제 GoAWK 인터프리터를 Go 1.2부터 1.18까지 모든 버전에 걸쳐 두 가지 벤치마크로 비교한 결과였습니다.

이번 글에서는 빠졌던 Go 버전(1.0과 1.1)과 새로 나온 버전(1.19부터 1.22)을 추가해 해당 벤치마크를 다시 실행했습니다. Go 1.20에서 추가된 프로파일 기반 최적화(PGO)를 적용한 결과도 포함했습니다. 기존 글을 다시 읽지 않아도 설정을 이해할 수 있도록 원문 내용을 상당 부분 인용하겠습니다.

Go로 작성된 프로그램이 빨라진 이유는 다양합니다. Go 팀과 외부 기여자들이 컴파일러를 개선하고 런타임과 가비지 컬렉터, 표준 라이브러리를 최적화해 왔기 때문입니다. 여기서는 집필 시점의 최신 버전인 Go 1.0부터 1.22까지 각 릴리스 버전으로 컴파일했을 때의 GoAWK 성능을 비교합니다.

서로 다른 극단을 대표하는 두 AWK 프로그램으로 GoAWK를 실행해 이를 테스트했습니다. 하나는 문자열 처리를 동반한 I/O 작업이고, 다른 하나는 수치 연산 작업입니다.

먼저 countwords입니다. 입력에서 단어 빈도를 세어 단어와 개수를 출력하는 문자열 처리 작업으로, AWK 스크립트의 전형적인 예에 해당합니다. 입력으로는 킹 제임스 성경을 10배로 이어 붙인 파일을 사용했습니다(예전에 성능 비교에 사용한 적 있는 파일입니다). 코드는 다음과 같습니다.

{
    for (i=1; i<=NF; i++)
        counts[tolower($i)]++
}

END {
    for (k in counts)
        print k, counts[k]
}

두 번째 프로그램은 sumloop로, 루프 카운터를 변수에 여러 번 더하는 타이트한 루프입니다. AWK의 전형적인 쓰임새라고 하긴 어렵지만 GoAWK 바이트코드 인터프리터 루프를 테스트하기에는 좋습니다.

BEGIN {
    for (i=0; i<10000000; i++)
        sum += i+i+i+i+i
}

오래된 Go 버전에서도 컴파일되도록 GoAWK 코드를 약간 수정해야 했습니다. 특히 Go 1.0의 경우 GoAWK에서 많이 사용하는 bufio.Scanner가 없기 때문입니다. 1.0에서는 Go 1.1의 bufio.Scanner 구현을 사용했습니다.

차트의 시간 수치는 제 x86-64 Linux 노트북에서 측정한 초 단위 시간입니다(세 번 실행 중 최고 기록). 파란 선은 countwords, 빨간 선은 sumloop입니다(참고로 지난번에는 결과를 잘못 표기했었습니다). 최근 버전의 미세한 개선을 더 명확하게 보여주기 위해 이번에는 Y축이 로그 스케일이라는 점에 유의해 주세요.

차트에는 Go 버전별 GoAWK 바이너리 크기도 함께 표시되어 있습니다. 연한 회색 선이 이에 해당합니다.

이번에도 모두 실행하고 시간을 측정하는 데 Python 스크립트를 사용했습니다. 결과 차트는 다음과 같습니다(표로 보기를 선호한다면 표로도 확인할 수 있습니다).

Go 버전별 GoAWK 속도

가장 큰 성능 향상은 1.3, 1.5, 1.7, 1.12 버전에서 나타났습니다. 그 이후로는 매우 점진적인 속도 향상만 있었습니다. 쉽게 얻을 수 있는 성과는 이미 오래전에 다 거둔 셈입니다.

이번에는 Go 1.2에서 countwords의 이상한 성능 저하가 있었습니다. 1.1에서는 7.5초였는데 1.2에서는 25.5초(!)로 늘어났다가 1.3에서는 2.8초로 다시 떨어졌습니다. 이는 거의 확실히 스택 “hot split” 문제 때문이며, Go 팀이 “고루틴 스택 구현을 기존의 ‘segmented’ 모델에서 연속(contiguous) 모델로 변경”하면서 1.3에서 수정되었습니다.

프로파일링을 통해 1.2의 이상 현상 원인을 파악했는데, 런타임의 스택 연산이 실행 시간의 상당 부분을 차지하고 있었습니다. pprof 출력의 처음 몇 줄은 다음과 같습니다.

$ go tool pprof --text ./goawk_1.2 go12.prof 
Total: 1830 samples
     332  18.1%  18.1%      332  18.1% runtime.newstack
     296  16.2%  34.3%      296  16.2% runtime.memclr
     281  15.4%  49.7%      281  15.4% runtime.oldstack
     222  12.1%  61.8%      619  33.8% github.com/benhoyt/goawk/interp.(*interp).execute
      91   5.0%  66.8%       91   5.0% runtime.lessstack
      75   4.1%  70.9%      133   7.3% github.com/benhoyt/goawk/interp.(*interp).callBuiltin
      57   3.1%  74.0%       57   3.1% runtime.stackfree
      53   2.9%  76.9%       81   4.4% strings.FieldsFunc
      ...

PGO는 성능을 불과 몇 퍼센트만 향상시킵니다. Go 1.22 기준으로 countwords는 약 2%, sumloop는 약 7% 정도입니다. 릴리스되는 GoAWK 바이너리는 PGO를 적용해 컴파일합니다.

바이너리 크기는 수년간 상당히 안정적으로 유지되었습니다. 1.2에서의 큰 증가를 제외하면 그렇습니다. PGO를 활성화해도 바이너리는 약 5% 정도만 커지므로 대체로 그만한 가치가 있다고 생각합니다.

전체적으로 보면 countwords는 Go 1.0으로 컴파일했을 때보다 지금 약 8배 빠르고, sumloop는 24배 빠릅니다. 오랜 기간 수고해 주신 Go 팀에 감사드립니다!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글