Optimizing GoAWK with a bytecode compiler and virtual machine

Ben Hoyt

바이트코드 컴파일러와 가상 머신으로 GoAWK 최적화하기

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

요약: 최근 트리 순회 인터프리터에서 바이트코드 컴파일러와 가상 머신 인터프리터로 전환해 GoAWK의 속도를 높였습니다. 왜 더 빠른지, 그리고 새로운 인터프리터가 어떻게 동작하는지 설명합니다.

업데이트: GoAWK는 이제 CSV 파일 지원을 네이티브로 포함합니다.

몇 년 전 저는 Go로 작성된 AWK 인터프리터인 GoAWK를 만들었고, 그 동작 방식과 테스트 방법, 그리고 더 빠르게 만든 과정을 설명하는 도 함께 썼습니다.

GoAWK는 재미있는 사이드 프로젝트였고, 규모가 꽤 큰 오픈소스 프로젝트인 Benthos 스트림 프로세서에서 적어도 하나 이상의 프로젝트에 사용되고 있습니다. 덕분에 지금 Canonical에서 일하게 되기도 했습니다.

GoAWK는 이전에 트리 순회 인터프리터를 사용했습니다. 코드 블록을 실행하기 위해 파싱된 구문 트리를 재귀적으로 순회하는 방식입니다. 매우 단순하지만 특별히 빠르지는 않습니다. 한동안 바이트코드 컴파일러와 가상 머신 인터프리터로 전환하고 싶었고, 마침내 실행에 옮겼습니다.

제가 초기에 했던 프로그래밍 프로젝트 중 하나는 Third라는 DOS용 Forth 컴파일러였습니다. Third를 포함한 대부분의 Forth 컴파일러는 바이트코드의 한 형태인, Forth 세계에서는 스레드 코드라 불리는 방식을 사용하는 단순한 컴파일러입니다. 그러니 25년 동안 가상 머신에 관심을 가져왔다고 할 수 있겠네요… 이게 저를 진짜 괴짜로 만드는 걸까요, 아니면 그냥 늙었다는 뜻일까요?

가상 머신은 왜 트리 순회보다 빠를까?

가상 명령어로 컴파일한 뒤 가상 머신으로 실행하는 것이 구문 트리를 평가하는 것(“트리 순회”)보다 왜 빠른지 바로 와닿지는 않습니다.

실제로는 사전 작업이 많습니다. 단순히 렉싱과 파싱으로 구문 트리를 만드는 대신, 이제 컴파일 단계까지 추가되기 때문입니다. 다만 가상 머신 컴파일러(GoAWK의 컴파일러 포함)는 대개 매우 단순하고 최적화를 하지 않으므로 그 단계는 빠릅니다.

실행이 더 빠른 이유 중 하나는 이것입니다. RAM(Random Access Memory)은 현대 프로세서에서 실제로는 랜덤 액세스가 아닙니다. 메모리 블록은 필요에 따라 빠른 CPU 캐시로 로드되므로, 새로운 블록에 접근해야 할 때는 캐시에 있는 경우보다 약 10배 더 오래 걸립니다. Peter Norvig가 정리한 일반적인 CPU에서의 다양한 연산 소요 시간 표를 보면 L1 캐시에서 가져오는 데 약 0.5나노초가 걸리고, L2 캐시에서는 그 14배, 메인 메모리에서는 다시 그 14배가 걸린다는 것을 알 수 있습니다!

이를 염두에 두고 프로그래밍하는 것을 “데이터 지향 설계”라고 합니다. Andrew Kelley의 훌륭한 강연인 A Practical Guide to Applying Data-Oriented Design을 보면서 이 접근이 얼마나 큰 영향을 미치는지 다시 깨달았습니다. Andrew는 Zig 프로그래밍 언어의 창시자로, 그의 강연에서는 데이터 지향 설계 기법을 적용해 Zig 컴파일러 속도를 크게 높인 과정을 설명합니다. 그 강연이 GoAWK에 대해 이를 고민하게 된 계기였습니다. 다시 본론으로 돌아와 가상 머신이 왜 트리 순회보다 빠른지 이야기해 보겠습니다…

구문 트리는 다른 노드를 가리키는 노드 구조체들의 모음입니다. 메모리에 흩어져 있기 때문에 자식 노드를 평가하려면 포인터를 따라가며 RAM 이곳저곳을 점프해야 하고, 그 과정에서 이미 캐시에 있던 내용을 밀어낼 수도 있습니다.

다음은 print $1+$2 표현식에 대한 GoAWK 구문 트리 다이어그램으로, 각 노드 이름 위에 16진수 메모리 주소를 표시한 것입니다:

‘print $1+$2’에 대한 구문 트리

PrintStmtBinaryExpr에서 불과 48바이트 떨어져 있지만, 왼쪽 FieldExpr는 거기서 8KB 떨어져 있고, 그 안의 NumExpr는 또 거기서 거의 120KB나 떨어져 있습니다. 캐시 블록은 보통 64바이트이므로, 각각 메인 메모리에서 추가 캐시 블록을 로드해야 할 가능성이 큽니다. 캐시 친화적이지 않은 구조입니다.

가상 머신 인터프리터에서는 명령어들이 opcode(명령어 번호)의 깔끔한 선형 배열에 담겨 있어, 아마도 한 번에 캐시 블록으로 로드될 것입니다. RAM을 이리저리 점프하는 일이 훨씬 적습니다. 동일한 프로그램에 대한 GoAWK 가상 머신 명령어는 다음과 같이 생겼습니다(이 “어셈블리 목록”은 새로운 디버그 플래그인 goawk -da로 확인할 수 있습니다):

$ echo 3 4 | goawk -da '{ print $1+$2 }'
        // { body }
0000    FieldInt 1
0002    FieldInt 2
0004    Add
0005    Print 1    // 1 is the number of values to print

7

여기서 GoAWK 컴파일러가 수행하는 (비교적 적은) 최적화 중 하나가 드러납니다. $i에서 i가 정수 상수일 때 Num i 뒤에 Field가 오는 2개 명령어 시퀀스 대신 단일 FieldInt i 명령어로 변환합니다. 덕분에 대부분의 필드 조회는 opcode 디코딩 루프를 두 번이 아니라 한 번만 거치게 됩니다.

가상 머신 방식이 더 빠른 또 다른 이유는 함수 호출이 더 적기 때문이며, 함수 호출은 상대적으로 느립니다. 구문 트리를 평가할 때는 eval 함수가 자식 노드를 평가하기 위해 eval을 재귀적으로 다시 호출합니다. 가상 머신에서는 이 모든 것이 루프로 순회하는 단일 opcode 배열로 평탄화되므로 opcode를 디스패치하는 데 함수 호출이 필요하지 않습니다.

컴파일러와 가상 머신 세부 사항

GoAWK의 가상 머신은 32비트 opcode를 사용합니다. 처음에는 8비트 opcode(“바이트코드”의 “바이트”가 여기서 나왔습니다)를 쓰려고 했지만, 32비트 opcode도 똑같이 빨랐고, 32비트 opcode를 사용하면 가변 크기 점프 오프셋이 필요하지 않습니다. 큰 AWK 스크립트에서는 -128~+127 범위를 넘는 점프 오프셋이 필요할 수 있는데, 32비트가 제공하는 20억을 넘는 점프 오프셋이 필요한 사람은 없을 것입니다. 64비트 opcode는 불필요하게 크고 약간 더 느리기도 했습니다.

다음은 처음 10개의 opcode입니다(총 85개이며, 전체 목록은 internal/compiler/opcodes.go에서 볼 수 있습니다):

// Opcode represents a single virtual machine instruction (or argument).
// The comments beside each opcode show any arguments that instruction
// consumes.
type Opcode int32

const (
    Nop Opcode = iota

    // Stack operations
    Num // numIndex
    Str // strIndex
    Dupe
    Drop
    Swap

    // Fetch a field, variable, or array item
    Field
    FieldInt    // index
    Global      // index
    Local       // index
    ...
)

위의 print $1+$2 어셈블리 목록에서 볼 수 있듯이 저는 스택 기반 가상 머신을 사용하고 있습니다. 컴파일러가 레지스터 할당을 고민할 필요 없이 스택에 push하고 pop하기만 하면 되므로 구현이 더 간단합니다. 다만 스택 기반 가상 머신은 약간 더 느릴 수 있습니다. Lua처럼 매우 빠른 가상 머신은 레지스터 기반입니다.

GoAWK의 컴파일러는 꽤 단순하며, 구문 트리에서 명령어로 비교적 직접적으로 변환합니다. 서로 다른 스코프의 변수 접근에 대해서는 약간의 특수화를 사용합니다. 예를 들어 전역 변수를 가져올 때는 Global 명령어를, 지역 변수를 가져올 때는 Local을 사용합니다. (보시다시피 제 명령어 명명 방식은 극도로 창의적입니다.)

다음은 1부터 10까지의 수를 합산하는 간단한 프로그램의 어셈블리 목록입니다:

$ goawk -da 'BEGIN { for (i=1; i<=10; i++) sum += i; print sum }'
        // BEGIN
0000    Num 1 (0)
0002    AssignGlobal i
0004    Global i
0006    Num 10 (1)
0008    JumpGreater 0x0018
000a    Global i
000c    AugAssignGlobal AugOpAdd sum
000f    IncrGlobal 1 i
0012    Global i
0014    Num 10 (1)
0016    JumpLessOrEqual 0x000a
0018    Global sum
001a    Print 1

55

여기에는 Python에서 가져온 깔끔한 최적화가 하나 보입니다. Python 인터프리터는 Python 3.10에서 이를 추가했습니다(새로운 아이디어는 아닐 거라고 확신합니다). forwhile 루프를 컴파일할 때 가장 단순한 방법은 루프 상단에서 조건을 검사하고 루프 하단에서 무조건 Jump를 사용하는 것입니다. 하지만 그렇게 하면 루프마다 두 개의 점프 명령어를 실행하게 됩니다. 하나는 상단에서, 하나는 하단에서입니다.

대신 우리는 조건을 두 번 컴파일합니다. 루프 전에 한 번 뒤집어서(JumpGreater), 그리고 루프 하단에서 한 번(JumpLessOrEqual)입니다. 조건이 반복되므로 전체 코드량은 약간 더 늘어나지만, 정작 중요한 루프 자체는 점프 명령어 하나가 줄어듭니다.

연산의 타입을 미리 알 때 정수나 문자열용 특수 명령어를 추가하는 식으로 명령어 집합을 더 개선할 수 있을 것입니다. 하지만 그렇게 하면 복잡도가 올라가므로, 지금은 단순하게 유지하려고 합니다.

GoAWK 컴파일러가 하는 또 다른 최적화는 대입에 대한 것입니다. AWK에서 대입은 표현식이므로 기본적으로는 그 값을 스택에 push했다가… 대부분의 경우 즉시 버리게 됩니다. 그리고 대입 표현식의 값을 사용하는 경우는 드뭅니다.

다음은 최적화된 대입 표현식의 어셈블리입니다:

$ ./goawk -da 'BEGIN { x=42; print x }'
        // BEGIN
0000    Num 42 (0)
0002    AssignGlobal x
0004    Global x
0006    Print 1

42

그리고 최적화가 없을 때 어떻게 보일지는 다음과 같습니다:

0000    Num 42 (0)
0002    Dupe              # unnecessary
0003    AssignGlobal x
0005    Drop              # unnecessary
0006    Global x
0008    Print 1

아래는 이 최적화에 사용된 특수 케이스를 보여주는 구문을 컴파일하는 코드입니다. 전혀 다른 예로 if문을 어떻게 컴파일하는지도 함께 넣었습니다. 컴파일러가 Go의 타입 스위치를 많이 활용하는 점에 주목해 주세요:

func (c *compiler) stmt(stmt ast.Stmt) {
    switch s := stmt.(type) {
    case *ast.ExprStmt:
        // Optimize assignment expressions to avoid extra Dupe and Drop
        switch expr := s.Expr.(type) {
        case *ast.AssignExpr:
            c.expr(expr.Right)
            c.assign(expr.Left)
            return

        case *ast.IncrExpr:
            ... // similar optimization for i++ and i--

        case *ast.AugAssignExpr:
            ... // similar optimization for i+=2 (for example)
        }

        // Non-optimized ExprStmt: push value and then drop it
        c.expr(s.Expr)
        c.add(Drop)

    ...

    case *ast.IfStmt:
        if len(s.Else) == 0 {
            jumpOp := c.condition(s.Cond, true)
            ifMark := c.jumpForward(jumpOp)
            c.stmts(s.Body)
            c.patchForward(ifMark)
        } else {
            jumpOp := c.condition(s.Cond, true)
            ifMark := c.jumpForward(jumpOp)
            c.stmts(s.Body)
            elseMark := c.jumpForward(Jump)
            c.patchForward(ifMark)
            c.stmts(s.Else)
            c.patchForward(elseMark)
        }

    ...
    }
}

가상 머신의 execute 함수는 하나의 for 루프와 큰 switch 문으로 이루어져 있으며, 각 opcode마다 하나의 case가 있습니다. 다음은 명령어 페치와 몇몇 opcode를 처리하는 코드를 보여주는 일부입니다:

func (p *interp) execute(code []compiler.Opcode) error {
    for ip := 0; ip < len(code); {
        op := code[ip]
        ip++

        switch op {
        case compiler.Num:
            index := code[ip]
            ip++
            p.push(num(p.nums[index]))

        case compiler.Str:
            index := code[ip]
            ip++
            p.push(str(p.strs[index]))

        case compiler.Dupe:
            v := p.peekTop()
            p.push(v)

        ...

        case compiler.FieldInt:
            index := code[ip]
            ip++
            v, err := p.getField(int(index))
            if err != nil {
                return err
            }
            p.push(v)

        ...
        }
    }
}

Go의 switch 문

위에서 보았듯이 가상 머신은 opcode당 하나의 case를 가진 큰 switch 문으로 구현되어 있습니다(약 80개의 case). Go의 switch 문은 현재 “case 공간”에 대한 이진 탐색으로 구현되어 있습니다. 이를 다음과 같이 컴파일된다고 생각할 수 있습니다. 간결함을 위해 트리의 일부 분기만 전체로 표시했습니다:

if op < 40 {
    if op < 20 {
        if op < 10 {
            if op < 5 {
                if op < 2 {
                    if op < 1 {
                        // handle opcode 0
                    } else {
                        // handle opcode 1
                    }
                } else {
                    // cases for opcodes 2-4
                }
            } else {
                // cases for opcodes 5-9
            }
        } else {
            // cases for opcodes 10-19
        }
    } else {
        // cases for opcodes 20-39
    }
} else {
    if op < 60 {
        // cases for opcodes 40-59
    } else {
        // cases for opcodes 60-79
    }
}

보시다시피 관심 있는 case에 도달하려면 O(log2 N)번의 비교와 점프가 필요합니다. opcode가 80개라면 명령어 하나를 디코딩할 때마다 6~7개의 분기를 거쳐야 합니다.

명령어 수가 늘어날수록 분기 수도 늘어납니다(다행히 그 증가는 선형이 아니라 로그 규모입니다). 처음 GoAWK용 개념 증명 가상 머신을 만들고 데모에 필요한 7~8개 명령어만 구현했을 때는 switch에 case가 몇 개 없었기 때문에 거의 40%나 빨라지는 큰 성능 향상이 있었습니다. 하지만 지금은 모든 opcode를 갖춘 상태라 “고작” 18% 빨라진 수준입니다.

opcode가 100개쯤 있을 때는 그보다 더 느렸습니다. 속도를 높일 거라 생각했던 일부 특수화를 제거했는데, opcode가 줄어들면서 이진 탐색 분기가 하나 줄어들었고 실제로 평균 12% 더 빨라졌습니다.

명령어가 몇 개든 상수 시간에 명령어를 디스패치할 수 있는 방법이 있다면 좋을 것입니다. 왜 Go는 switch를 점프 주소 테이블로 구현할 수 없을까요? 테이블에서 코드 주소를 찾아 바로 점프하는 방식 말입니다. 알고 보니 Go 팀의 Keith Randall이 바로 그 작업을 하고 있어 Go 1.19에서 만나볼 수 있을지도 모릅니다.

저는 Keith의 브랜치(현재는 int64 타입에서만 동작합니다)를 GoAWK에 적용해 봤고, 간단한 마이크로벤치마크의 속도가 10% 향상되었습니다. 그래서 Go 컴파일러가 “점프 테이블”을 지원하게 될 날을 정말 기대하고 있습니다.

우리가 직접 이 최적화를 할 수는 없을까요? 함수 배열은 어떨까요? 저는 아래와 같이 디스패치 루프를 바꿔 시도해 봤습니다:

func (p *interp) execute(code []compiler.Opcode) error {
    for ip := 0; ip < len(code); {
        op := code[ip]
        ip++

        n, err := vmFuncs[op](p, code, ip)
        if err != nil {
            return err
        }
        ip += n
    }
    return nil
}

// Type of function called for each instruction. Each function returns
// the number of arguments the instruction read from code[ip:].
type vmFunc func(p *interp, code []compiler.Opcode, ip int) (int, error)

var vmFuncs [compiler.EndOpcode]vmFunc

func init() {
    vmFuncs = [compiler.EndOpcode]vmFunc{
        compiler.Nop: vmNop,
        compiler.Num: vmNum,
        compiler.Str: vmStr,
        ...
    }
}

func vmNop(p *interp, code []compiler.Opcode, ip int) (int, error) {
    return 0, nil
}

func vmNum(p *interp, code []compiler.Opcode, ip int) (int, error) {
    index := code[ip]
    p.push(num(p.nums[index]))
    return 1, nil
}

func vmStr(p *interp, code []compiler.Opcode, ip int) (int, error) {
    index := code[ip]
    p.push(str(p.strs[index]))
    return 1, nil
}

이 방법은 GoAWK 마이크로벤치마크에서 1~2%의 속도 향상에 그쳤습니다(결과와 코드 보기). 결국 저는 더 단순한 switch 코드를 유지하고 다른 방법으로 속도를 개선하기로 했습니다. 그리고 Go 컴파일러가 switch에 대해 점프 테이블을 지원하게 되면 아무것도 하지 않고도 10% 향상을 얻게 될 것입니다!

gcc 컴파일러에는 “computed goto”라는 비표준 기능이 있는데, 이를 이용하면 각 opcode 코드 끝에 goto *dispatch_table[code[ip++]] 같은 코드를 써서 다음 opcode의 코드로 직접 점프할 수 있습니다. Eli Bendersky가 computed goto에 대한 훌륭한 글을 썼으니 여기서는 더 자세히 다루지 않겠습니다. CPython을 비롯한 C로 작성된 대부분의 가상 머신은 이 기법을 사용합니다. 안타깝게도 Go에는 computed goto가 없지만, 다시 말해 switch가 점프 테이블로 컴파일되면 그 절반 정도는 달성하게 됩니다.

컴파일러가 switch를 어떻게 최적화할 수 있는지에 대해 좀 더 학술적인 글을 읽고 싶다면, 2008 GCC Developers’ Summit에서 발표된 Roger Sayle의 논문 “A Superoptimizer Analysis of Multiway Branch Code Generation [PDF]”를 읽어보세요.

기타 최적화(그리고 하나의 비최적화)

트리 순회에서 가상 머신으로의 전환 외에도 최근 몇 가지 다른 최적화를 추가했습니다:

  • 명령어로 파이프되는 출력 버퍼링으로 print 리다이렉션 속도가 10배 빨라졌습니다. 원래 stdout에 버퍼링을 추가했을 때 모든 print 출력에서 비슷한 속도 향상을 얻었지만, 리다이렉트되는 경우에 추가하는 것을 빠뜨렸습니다.
  • 필요할 때만 숫자 문자열을 숫자로 변환하도록 했습니다. 이전에는 변환을 즉시 수행했지만, 이제는 값이 숫자 컨텍스트에서 필요할 때만 변환합니다. 이는 $i 필드를 문자열로 사용하는 실제 스크립트에서 확실한 개선을 주지만, 비교 연산은 느려졌습니다. 제법 현실적인 단어 수 세기 스크립트에서는 40% 속도 향상을 가져왔습니다.
  • 제가 한 strings.TrimSpace 최적화가 Go 1.13에 포함되어, 이제 GoAWK가 최소 Go 1.13을 요구하므로 제가 만든 커스텀 TrimSpace제거할 수 있었습니다.

제가 했던 문제 있는 수정 중 하나는 length()substr() 같은 GoAWK의 문자열 함수들이 바이트 인덱스 대신 유니코드 문자 인덱스를 사용하도록 바꾼 것이었습니다. 이로 인해 이들 연산이 문자열 길이에 대해 O(1)에서 O(N)으로 바뀌리라는 것을 알고 있었지만, “N은 보통 작다”는 생각에 큰 문제가 되지 않을 거라 여겼습니다.

그 가정은 틀린 것으로 드러났습니다. Volodymyr Gubarkov의 gron.awk 스크립트는 큰 JSON 파일을 처리하는 데 1초에서 8분 이상으로 늘어났습니다. 의도치 않은 이차 함수(accidentally quadratic)가 된 것입니다. 이는 유지할 수 없는 수준이었기에 일단 그 수정을 되돌리고, 향후 O(1) 방식으로 해결할 방법을 찾기로 했습니다. 오랫동안 Gawk를 유지보수해 온 Arnold Robbins는 Gawk가 문자열 처리를 효율적으로 만들기 위해 많은 노력을 기울인다고 언급했습니다.

앞으로 GoAWK를 더 최적화하고 싶어 향후 성능 작업을 추적하기 위한 umbrella 이슈를 열어두었습니다. 몇 가지 아이디어는 다음과 같습니다:

가상 머신 개선. 가상 머신 속도를 높이기 위해 고려 중인 몇 가지는 다음과 같습니다:

  • 스택 연산을 최적화하거나 줄이기. interp.push 메서드는 append 검사 때문에 특히 느립니다(그리고 일반 AWK 코드에서는 append가 거의 필요하지 않습니다). 최대 스택 크기를 미리 파악하는 좋은 아이디어가 있다면 알려주세요. 잠재적으로 재귀적인 함수 호출이 있는 상황에서도 가능할까요?
  • 추가할 수 있는 특수 opcode가 있을까? 예를 들어 인자에 있는 정수 상수를 push하는 Int 명령어 같은 것입니다. Int를 추가하면 interp.nums 슬라이스에 대한 메모리 조회를 아낄 수 있습니다.
  • 아마도 JumpLess와 비슷한 opcode는 문자열에 자주 사용되지 않을 것입니다. 피연산자 중 적어도 하나에 대한 타입 검사를 피하기 위해 이를 JumpLessNum으로 교체하는 것이 더 좋을까요? (문자열의 경우 더 긴 명령어 시퀀스를 사용하게 됩니다.)

문자열 연결도 두 개 이상의 문자열을 연결할 때 과도한 할당과 복사로 인해 불필요하게 비용이 큽니다. 현재 first_name " " last_name 같은 다중 연결 표현식은 두 개의 이항 Concat 명령어로 컴파일됩니다:

Global first_name
Str " "
Concat
Global last_name
Concat

컴파일러가 이를 감지해 새로운 Concat numArgs 명령어를 출력하면 더 효율적일 것입니다. 예를 들면 다음과 같습니다:

Global first_name
Str " "
Global last_name
Concat 3

이는 명령어가 하나 줄어들 뿐만 아니라, 더 중요하게는 임시 문자열을 할당했다가 다시 새로 할당해 바이트를 복사하는 과정을 피할 수 있습니다. 두 개 이상의 값을 연결하는 일은 AWK에서 꽤 흔하며, 연결하는 값이 많을수록 이 최적화의 효과는 더 커집니다.

정규 표현식도 속도를 높이면 좋을 부분입니다. GoAWK는 현재 Go의 regexp 패키지를 사용하지만 안타깝게도 상당히 느립니다. 이 때문에 정규 표현식을 많이 사용하는 AWK 스크립트는 Gawk의 절반, Mawk의 4분의 1 수준의 속도에 머무릅니다.

이를 개선하는 방법은 두 가지가 있습니다:

  1. 직접 정규 표현식 엔진을 작성한다(아마도 Mawk의 엔진을 Go로 직접 포팅하는 방식). 이는 아마도 많은 작업이 필요하고, Go의 경계 검사와 적은 컴파일러 최적화 때문에 여전히 그리 빠르지 않을 수도 있습니다.
  2. Go의 정규 표현식 엔진 속도를 개선한다. 이는 Go의 regexp 패키지를 사용하는 모든 사람이 혜택을 받으므로 훨씬 더 나은 방법입니다. 다만 역시 꽤 어려울 가능성이 큽니다. 이 부분은 저보다 더 똑똑한 사람들에게 맡길까 합니다. 어쩌면 이 이슈들 중 일부가 시간이 지나면서 해결될지도 모릅니다.

가상 머신 결과

그렇다면 가상 머신 인터프리터는 얼마나 더 빠를까요? 마이크로벤치마크는 인정하건대 대부분 AWK로 작성할 법한 스크립트는 아니지만, 전체적으로 약 18% 빨라졌습니다. 이 수치는 경과 시간이므로 작을수록 좋습니다(원본을 보거나 benchmark.sh에 이어 benchstat.sh를 실행해 직접 측정하고 차이를 확인할 수 있습니다):

name                    old time/op  new time/op  delta
NativeFunc-8            10.7µs ± 0%  10.8µs ± 0%   +0.67%
BuiltinGsub-8           16.2µs ± 0%  16.2µs ± 0%   +0.36%
BuiltinGsubAmpersand-8  16.2µs ± 0%  16.2µs ± 0%   +0.29%
BuiltinSub-8            13.6µs ± 0%  13.6µs ± 0%     ~   
BuiltinSubAmpersand-8   13.5µs ± 0%  13.6µs ± 0%     ~   
SimplePattern-8          133ns ± 1%   134ns ± 0%     ~   
ConcatLarge-8           8.43ms ± 1%  8.35ms ± 2%     ~   
BuiltinSplitRegex-8     87.9µs ± 0%  87.7µs ± 0%   -0.21%
BuiltinSplitSpace-8     35.4µs ± 0%  35.1µs ± 0%   -0.70%
GetField-8               445ns ± 1%   435ns ± 2%   -2.42%
FuncCall-8              2.84µs ± 0%  2.76µs ± 2%   -2.65%
BuiltinSprintf-8        9.67µs ± 0%  9.23µs ± 0%   -4.58%
RecursiveFunc-8         15.7µs ± 0%  14.9µs ± 0%   -4.95%
ConcatSmall-8            735ns ± 0%   691ns ± 1%   -5.98%
BuiltinMatch-8          2.91µs ± 0%  2.71µs ± 1%   -7.02%
BuiltinIndex-8          1.23µs ± 1%  1.11µs ± 1%   -9.51%
RegexMatch-8            1.24µs ± 1%  1.11µs ± 4%  -10.07%
SetField-8               905ns ± 0%   810ns ± 0%  -10.45%
ForInLoop-8             2.04µs ± 2%  1.78µs ± 4%  -12.86%
ArrayOperations-8        657ns ± 0%   565ns ± 0%  -13.94%
BinaryOperators-8        493ns ± 0%   413ns ± 0%  -16.15%
BuiltinSubstr-8          975ns ± 0%   765ns ± 0%  -21.50%
Comparisons-8            417ns ± 0%   321ns ± 0%  -22.98%
SimpleBuiltins-8        1.00µs ± 0%  0.75µs ± 0%  -25.61%
CondExpr-8               203ns ± 0%   151ns ± 0%  -25.62%
BuiltinLength-8          607ns ± 0%   429ns ± 0%  -29.34%
IfStatement-8            219ns ± 0%   152ns ± 0%  -30.65%
AugAssign-8             1.50µs ± 0%  0.98µs ± 0%  -34.74%
LocalVars-8              479ns ± 0%   300ns ± 2%  -37.32%
Assign-8                 446ns ± 0%   261ns ± 0%  -41.55%
ForLoop-8               4.34µs ± 0%  2.50µs ± 0%  -42.39%
GlobalVars-8             468ns ± 0%   269ns ± 1%  -42.48%
IncrDecr-8               448ns ± 0%   148ns ± 0%  -66.87%
[Geo mean]              2.36µs       1.94µs       -17.90%

증가, 감소 및 복합 대입이 훨씬 더 빨라진 이유는 가상 머신에 이들을 위한 전용 opcode가 있기 때문입니다. 변수 접근도 상당히 개선되었고, for 루프, if 문, 이항 연산자 및 다른 많은 벤치마크도 마찬가지입니다.

제가 원본 AWK 소스에서 대부분 가져온 좀 더 “현실적인” 벤치마크 모음은 전체적으로 13% 빨라졌습니다. 이 표에서 goawk는 새로운 가상 머신 인터프리터이고 orig는 기존 트리 순회 인터프리터입니다. 다소 직관에 반하게도, 여기서 숫자는 원본 awk보다 몇 배 빠른지를 나타내므로 클수록 좋습니다.

테스트goawkorigawkgawkmawk
tt.01 (print)2.021.911.001.662.29
tt.02 (print NR NF)1.591.601.001.772.20
tt.02a (print length)1.541.561.001.732.05
tt.03 (sum length)1.321.271.003.851.83
tt.03a (sum field)1.291.261.004.081.79
tt.04 (printf fields)0.970.801.001.262.74
tt.05 (concat fields)0.950.881.001.612.26
tt.06 (count lengths)1.391.351.002.531.97
tt.07 (even fields)1.241.181.001.461.71
tt.08 (even lengths)1.971.981.001.132.70
tt.09 (regex starts with)2.132.131.002.415.01
tt.10 (regex ends with)0.340.341.001.453.40
tt.10a (regex ends with var)0.320.341.001.303.08
tt.11 (substr)2.202.131.001.153.68
tt.12 (update fields)1.211.191.001.701.78
tt.13 (array ops)3.142.621.003.115.92
tt.13a (array printf)2.251.801.001.864.85
tt.14 (function call)1.171.091.000.641.56
tt.15 (format lines)0.620.611.000.962.21
tt.16 (count words)1.471.211.001.272.12
tt.big (complex program)1.621.411.001.833.82
tt.x1 (mandelbrot)2.251.621.001.343.44
tt.x2 (sum loop)1.761.031.001.152.68
기하 평균1.451.281.001.862.32

결론

저는 얻은 성능 향상이 확실히 마음에 듭니다. 기대만큼 크지는 않았지만, 이제 GoAWK가 많은 CPU 바운드 연산에서 Gawk보다 빠르다는 사실은 꽤 멋집니다. 여전히 성능에 집착하는 Mawk보다는 항상 느립니다. 그리고 AWK가 보통 사용되는 영역인 문자열 처리와 정규 표현식에서는 GoAWK가 아직 개선할 여지가 많습니다.

솔직히 말해, 추가된 2,500줄의 코드(테스트 포함 15,000줄짜리 프로젝트에서)가 그만한 가치가 있었는지 완전히 확신하지는 못합니다. 이 작업을 감독하는 엔지니어링 매니저가 있었다면 반대에 부딪혔을 것 같습니다(“이게 실제 워크로드에 도움이 될까요?”). 하지만 GoAWK는 지금도 열정 프로젝트였고 앞으로도 그럴 것입니다. 저는 이것을 만들고 공유하는 과정이 즐거웠고, 그것만으로 충분합니다.

저는 컴파일러와 가상 머신을 병합해 GoAWK v1.15.0으로 릴리스했습니다. Go API와 goawk 명령은 100% 하위 호환되어야 합니다. 제 인터프리터 테스트는 물론 원본 AWK와 Gawk의 관련 테스트들에 대해서도 충분히 테스트했지만, 문제가 있다면 이슈를 등록해 주세요.

이 글을 즐기셨거나 무언가 배우셨기를 바랍니다. 피드백이나 아이디어가 있다면 주저하지 말고 연락해 주세요.

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

댓글