5 분 소요

런타임 시점에 객체를 유연하게 처리하기 위해 리플렉션은 인코딩 라이브러리에서 자주 사용됩니다. 더 빠른 성능을 얻기 위해서는 자동 코드 생성이나 전용 인터페이스를 활용하는 것이 좋지만, 추가적인 코드 관리를 필요로 하기 때문에 간편한 리플렉션을 선호하는 경우도 많습니다.

uniflow에서는 여러 연산 단위인 노드 간에 데이터를 자유롭게 전달 하기 위해 JSON과 유사한 타입을 새롭게 정의하여 활용합니다. 이 타입은 여러 노드에서 동시에 사용해도 문제가 없도록 불변하게 설계되었습니다.

type Value interface {
	Kind() Kind
	Compare(v Value) int
	Interface() any
}

이 타입은 종종 외부 라이브러리와 통신을 위해 다른 구조체나 표준 객체로 변환되어야 합니다. 이러한 변환 과정에서 유연하게 모든 타입에 대응하기 위해 리플렉션을 활용하여 타입에 대한 정보를 동적으로 처리합니다.

var httpPayload *HTTPPayload
err := primitive.Unmarshal(rawPayload, &httpPayload)

그러나 객체를 변환하는 과정에서 활용되는 리플렉션은 많은 자원을 소모합니다. GET /ping 요청에 대한 응답으로 pong을 반환하는 서버에서는 객체 변환 작업이 전체 CPU 자원의 27%를 소모했습니다.

- kind: http
  name: server
  address: :8000
  links:
    out:
      - name: route
        port: in

- kind: route
  name: route
  routes:
    - method: GET
      path: /ping
      port: out[0]
  links:
    out[0]:
      - name: pong
        port: io

- kind: snippet
  name: pong
  code: pong
  flat  flat%   sum%        cum   cum%
 0.03s  0.22% 87.61%      3.76s 27.73%  github.com/siyul-park/uniflow/pkg/encoding.(*EncoderGroup[go.shape.interface {},go.shape.interface { Compare(github.com/siyul-park/uniflow/pkg/primitive.Value) int; Interface() interface {}; Kind() github.com/siyul-park/uniflow/pkg/primitive.Kind }]).Encode

성능 문제를 해결하기 위해 리플렉션을 대체하는 효율적인 방법을 찾는 중에, go-json이라는 encoding/json과 호환되면서도 높은 성능을 보이는 라이브러리를 발견했습니다. 이 라이브러리는 버퍼 재사용, 컴파일 과정을 통한 리플렉션 최적화, 커스텀 리플렉션 라이브러리를 활용한 이스케이프 회피 등 다양한 방법을 사용하여 성능을 향상시킵니다.

특히, 컴파일 과정을 통한 리플렉션 최적화는 적용하기 쉽고 Go의 내부 런타임에 과도하게 접근하지 않아 안정성이 있는 것으로 보였습니다. 따라서 컴파일 과정을 통한 최적화를 우선 적용해보기로 결정했습니다.

이 방법은 변환 프로세스를 컴파일하는 과정을 추가합니다. 이를 통해 한 번만 이루어져야 하는 높은 비용의 연산들을 분리하고 런타임에서 사용하는 리플렉션을 최소화하여 각 필드의 주소에 직접 접근하거나 복사하는 방식으로 변환 프로세스를 구현합니다. 이러한 과정은 각 타입별로 단 한 번만 일어나며, 생성된 변환 프로세스는 리플렉션 사용을 최소화하여 빠른 속도를 보장합니다.

if typ.Elem().Kind() == reflect.Struct {
    var decoders []encoding.Encoder[*Map, unsafe.Pointer]
    for i := 0; i < typ.Elem().NumField(); i++ {
        field := typ.Elem().Field(i)
        tag := getMapTag(field)

        if !field.IsExported() || tag.ignore {
            continue
        }

        child, err := decoder.Compile(field.Type)
        if err != nil {
            return nil, err
        }

        offset := field.Offset
        alias := NewString(tag.alias)

        var dec encoding.Encoder[*Map, unsafe.Pointer]
        if tag.inline {
            dec = encoding.EncodeFunc[*Map, unsafe.Pointer](func(source *Map, target unsafe.Pointer) error {
                return child.Encode(source, unsafe.Pointer(uintptr(target)+offset))
            })
        } else {
            dec = encoding.EncodeFunc[*Map, unsafe.Pointer](func(source *Map, target unsafe.Pointer) error {
                value, ok := source.Get(alias)
                if !ok {
                    if !tag.omitempty {
                        return errors.WithMessage(encoding.ErrInvalidValue, fmt.Sprintf("key(%v) is zero value", field.Name))
                    }
                    return nil
                }
                return child.Encode(value, unsafe.Pointer(uintptr(target)+offset))
            })
        }

        decoders = append(decoders, dec)
    }

    return encoding.EncodeFunc[Value, unsafe.Pointer](func(source Value, target unsafe.Pointer) error {
        if s, ok := source.(*Map); ok {
            for _, dec := range decoders {
                if err := dec.Encode(s, target); err != nil {
                    return err
                }
            }
            return nil
        }
        return errors.WithStack(encoding.ErrUnsupportedValue)
    }), nil
}

성능 최적화 이후에는 최대 3.25배까지 성능이 향상되는 결과를 얻었습니다. 그러나 타입을 미리 추론하기 어려운 map과 같은 경우에는 컴파일 단계에서 약간의 오버헤드가 발생하여 성능이 소폭 감소했습니다.

--Prev--
BenchmarkMap_Encode/map-16         	 1892743	       629.1 ns/op
BenchmarkMap_Encode/struct-16      	 1004965	      1176 ns/op
BenchmarkMap_Decode/map-16         	 1824111	       665.8 ns/op
BenchmarkMap_Decode/struct-16      	  990968	      1044 ns/op

--Curr--
BenchmarkMap_Encode/map-16         	 1585542	       748.2 ns/op
BenchmarkMap_Encode/struct-16      	 1810263	       656.2 ns/op
BenchmarkMap_Decode/map-16         	 2069302	       553.2 ns/op
BenchmarkMap_Decode/struct-16      	 3717460	       321.8 ns/op

AB(Apache HTTP server benchmarking tool)를 사용하여 ping 예제를 실험했을 때, 0.128ms가 걸렸던 요청이 0.090ms로 1.42배 빨라졌습니다.

--Prev--
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 204 requests
Completed 408 requests
Completed 612 requests
Completed 816 requests
Completed 1020 requests
Completed 1224 requests
Completed 1428 requests
Completed 1632 requests
Completed 1836 requests
Completed 2040 requests
Finished 2048 requests


Server Software:        
Server Hostname:        localhost
Server Port:            8000

Document Path:          /ping
Document Length:        4 bytes

Concurrency Level:      64
Time taken for tests:   0.262 seconds
Complete requests:      2048
Failed requests:        0
Total transferred:      268288 bytes
HTML transferred:       8192 bytes
Requests per second:    7817.00 [#/sec] (mean)
Time per request:       8.187 [ms] (mean)
Time per request:       0.128 [ms] (mean, across all concurrent requests)
Transfer rate:          1000.03 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   0.4      1       2
Processing:     1    7   2.4      8      14
Waiting:        1    7   2.3      8      14
Total:          2    8   2.4      8      15

Percentage of the requests served within a certain time (ms)
  50%      8
  66%      9
  75%     10
  80%     10
  90%     11
  95%     12
  98%     13
  99%     14
 100%     15 (longest request)

--Curr--
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 204 requests
Completed 408 requests
Completed 612 requests
Completed 816 requests
Completed 1020 requests
Completed 1224 requests
Completed 1428 requests
Completed 1632 requests
Completed 1836 requests
Completed 2040 requests
Finished 2048 requests


Server Software:        
Server Hostname:        localhost
Server Port:            8000

Document Path:          /ping
Document Length:        4 bytes

Concurrency Level:      64
Time taken for tests:   0.185 seconds
Complete requests:      2048
Failed requests:        0
Total transferred:      268288 bytes
HTML transferred:       8192 bytes
Requests per second:    11095.52 [#/sec] (mean)
Time per request:       5.768 [ms] (mean)
Time per request:       0.090 [ms] (mean, across all concurrent requests)
Transfer rate:          1419.45 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    2   0.8      2       6
Processing:     0    3   1.2      3       8
Waiting:        0    3   1.1      3       8
Total:          1    6   1.4      5      11

Percentage of the requests served within a certain time (ms)
  50%      5
  66%      6
  75%      6
  80%      7
  90%      8
  95%      8
  98%      9
  99%      9
 100%     11 (longest request)

cpu profile 상에서도 인코딩이 사용하는 자원이 5.08%로 감소한 것을 확인할 수 있었습니다.

  flat  flat%   sum%        cum   cum%
     0     0%   100%      0.06s  5.08%  github.com/siyul-park/uniflow/pkg/encoding.(*Assembler[go.shape.*uint8,go.shape.interface {}]).Encode

이 최적화 과정을 통해 만족할 만한 성능 향상을 이끌어 냈습니다. 더욱 성능을 높이기 위해 바이트 코드를 활용하여 익명 함수의 오버헤드를 줄이고 캐시 히트를 높이는 방식을 적용할 수 있지만 이러한 과정은 코드를 좀 더 복잡하게 만들 수 있습니다.

컴파일 과정을 추가하여 최적화를 적용하니, 인코딩 과정이 병목 현상을 유발하지 않게 되었습니다. 코드의 단순성과 가독성을 우선시하여 더 이상의 최적화를 진행하지 않고 추후 성능 개선이 요구되면 다른 방법들도 적용해 볼 것입니다.

댓글남기기