[t:/]$ 지식_

go 프로파일링과 배열 realloc

2020/06/09

간단한 프로파일링

인터넷 보고 따라하니까 전유성만큼 할 수 있었다.

https://flaviocopes.com/golang-profiling/

고마우신 분들이 프레임워크를 이미..

닥치고 설치

go get github.com/pkg/profile

먼저 소스를 짠다.

package main

import (
    "github.com/pkg/profile"
)

func main() {

    defer profile.Start(profile.ProfilePath("/home/keeptalk/go/src/re/")).Stop()

    q := make([]int, 0, 10)

    for i := 0; i < 1000000; i++ {
        q = append(q, i)
    }

}

go build re.go 해서 바이너리 만들고 ./re 로 실행하면 소스에 있는 위치에 cpu.pprof가 생긴다. 이제 프로파일링 실행하면 끗. 아래 화면이 프로파일링 화면이다. 이 예제에서는 용량을 10으로 작게 잡아서 append시에 realloc을 유도했는데, 결과적으로 memmove가 드릅게 시간을 많이 먹고 gc가 뭔가 한다고 뻐덕댄 느낌적 느낌을 받을 수 있다.

keeptalk@dawnsea-ubuntu:~/go/src/re$ go tool pprof --text ./re ./cpu.pprof
File: re
Type: cpu
Time: Jun 9, 2020 at 8:31am (KST)
Duration: 201.01ms, Total samples = 30ms (14.92%)
Showing nodes accounting for 30ms, 100% of 30ms total
      flat  flat%   sum%        cum   cum%
      10ms 33.33% 33.33%       10ms 33.33%  runtime.gcWriteBarrier
      10ms 33.33% 66.67%       10ms 33.33%  runtime.memmove
      10ms 33.33%   100%       10ms 33.33%  runtime.procyield
         0     0%   100%       10ms 33.33%  main.main
         0     0%   100%       10ms 33.33%  runtime.allocm
         0     0%   100%       10ms 33.33%  runtime.gcBgMarkWorker
         0     0%   100%       10ms 33.33%  runtime.gcBgMarkWorker.func2
         0     0%   100%       10ms 33.33%  runtime.gcDrain
         0     0%   100%       10ms 33.33%  runtime.growslice
         0     0%   100%       10ms 33.33%  runtime.main
         0     0%   100%       10ms 33.33%  runtime.markroot
         0     0%   100%       10ms 33.33%  runtime.markroot.func1
         0     0%   100%       10ms 33.33%  runtime.mcall
         0     0%   100%       10ms 33.33%  runtime.newm
         0     0%   100%       10ms 33.33%  runtime.park_m
         0     0%   100%       10ms 33.33%  runtime.resetspinning
         0     0%   100%       10ms 33.33%  runtime.scang
         0     0%   100%       10ms 33.33%  runtime.schedule
         0     0%   100%       10ms 33.33%  runtime.startm
         0     0%   100%       10ms 33.33%  runtime.systemstack
         0     0%   100%       10ms 33.33%  runtime.wakep

그럼 배열 realloc의 문제의 프라블럼을 araboza.

배열 realloc의 문제

배열은 다음과 같이 용량을 미리 구해서 만들 수 있다.

a := make([]int, 5, 100)

초기 길이가 5이고 용량이 100이다. 길이만큼 0으로 초기화 된다. go는 천재들이 만들었으니까 make에 의한 초기 할당은 스택을 쓸 것이다. 어마무시한 크기를 요청했다면 bss 같은 곳에서 끌어올지도 모르겠지만 여튼 천재들이 짠 메모리 배치 전략에 의해 돌 것이다. 사실 스택은 아키텍쳐에 따라서는 그냥 포인터 1개의 오르내림에 불과하므로 엄청 큰 스택이 가능한 언어일 수도 있다. 그렇다면 거의 비용이 들지 않으며 컴파일 타임에 결정된다. 길이만큼 0으로 쌔리는 비용이 들긴 하겠지만 cpu에 전용 명령어들이 있어서 뭐 후닥딱 끝날 것이다. 뭐 스택에 어마어마한 용량을 쓰는 코드는 짜지 않겠지만, 반복 호출되는 함수라면 비용이라고 할 순 있겠다.

그런데 용량이 커지면 반드시 realloc을 할 것이다. 뭐 대충 2의 승수로 늘린다는 것 같다. 이 용량 확장은 런타임시에만 알 수 있다. 따라서 go는 이제 힙에서 메모리를 가져온다. 힙 할당도 비용, 스택에서 힙으로 복사한다고 비용, 나중에 GC한다고 비용이 든다.

용량이 다시 커지면 어떨까? 이미 힙에 있는데 malloc 같은 힙 관리자가 realloc을 처리한다. 보통 잘 만든 메모리 할당자들은 여분을 갖고 있도록 잘 짜겠지만 여기까지 당도했다면 연속적 메모리에 할당이 불가능한 상황에 빠졌을 것이다. (이론적으로는 MMU에 의해 페이지 테이블의 파편을 묶어서 연속적 메모리로 처리할 수 있지 않나 잘 모르겠다... OS 손 놓은지 오래되서 헷갈리는데 누가 알면 페북에 글 좀 남겨주세요...) 그래서 또 힙에서 새로운 메모리를 얻고 이전 힙의 배열을 처음부터 다시 복사한다. 아이고 힘들어. 뭐 요즘은 memmove 따위 엄청 빠르긴 한데, 역시 반복 호출 작업이라면 이것도 비용이다.

그 다음, gc 전략에 의해 쓰고 버려진 힙은 반환될 것이다.

전부 것이다.. 것이다.. 라고 하는데 내가 어찌 알까. 쪼렙인데. go 해보겠다고 까보기 시작한지 얼마 안 댔음.

그리하야 영어로 써져있으면 뭔가 근거가 있어보이지 않음?

https://segment./blog/allocation-efficiency-in-high-performance-go-services/

인용하면 이렇다.

Backing arrays of slices that get reallocated because an append would exceed their capacity. In cases where the initial size of a slice is known at compile time, it will begin its allocation on the stack. If this slice’s underlying storage must be expanded based on data only known at runtime, it will be allocated on the heap.

해설 : 배열은 용량을 넘어서 append하면 realloc되는데 처음엔 스택에 잡고 있다가 넘치면 힙으로 가져감.

그리하야 상시적으로 realloc이 일어나는 구조라면 벡터 메모리가 갖는 캐싱/SIMD/파이프 스톨 회피의 장점을 포기하고 리스트 형으로 전략을 바꿔야 한다는 것이다.

별 거 아닌데 글만 길어졌네. 끗.









[t:/] is not "technology - root". dawnsea, rss