Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 574 – 대역 외 데이터를 지원하는 피클 프로토콜 5

Author:
Antoine Pitrou <solipsis at pitrou.net>
BDFL-Delegate:
Alyssa Coghlan
Status:
Final
Type:
Standards Track
Created:
23-Mar-2018
Python-Version:
3.8
Post-History:
28-Mar-2018, 30-Apr-2019
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이 PEP는 새로운 피클 프로토콜 버전과 이를 최대한 활용하기 위한 관련 API를 표준화할 것을 제안합니다:

  1. 대역 외 데이터 버퍼에 필요한 추가 메타데이터를 다루기 위한 새로운 피클 프로토콜 버전(5).
  2. __reduce_ex__ 구현이 대역 외 데이터 버퍼를 반환할 때 사용하는 새로운 PickleBuffer 타입.
  3. 피클링할 때 대역 외 데이터 버퍼를 처리하기 위한 새로운 buffer_callback 매개변수.
  4. 언피클링할 때 대역 외 데이터 버퍼를 제공하기 위한 새로운 buffers 매개변수.

이 PEP는 새 API를 사용하지 않는 사용자에게는 동작이 바뀌지 않음을 보장합니다.

근거

pickle 프로토콜은 원래 1995년에 임의의 Python 객체를 디스크에 영속적으로 저장하기 위해 설계되었습니다. 1995년 무렵 저장 매체의 성능을 고려하면, 디스크에 쓰기 전에 임시 데이터를 복사할 때의 RAM 대역폭 사용량 같은 성능 지표에 주목하는 것은 아마 무의미했을 것입니다.

오늘날 pickle 프로토콜은 데이터 대부분이 디스크에 영속화되지 않는 애플리케이션에서 점점 더 많이 사용되고 있습니다(영속화되는 경우에도 Python 전용 형식 대신 이식 가능한 형식을 사용합니다). 그 대신 피클은 같은 머신에서든 여러 머신에 걸쳐서든, 한 프로세스에서 다른 프로세스로 데이터와 명령을 전송하는 데 사용되고 있습니다. 이러한 애플리케이션은 때때로 여기저기로 전송해야 하는 매우 큰 데이터(Numpy 배열이나 Pandas 데이터프레임 등)를 다룹니다. 이러한 애플리케이션에서 현재의 피클은 직렬화되는 데이터를 불필요하게 메모리에 복사하게 만들므로 낭비가 큽니다.

실제로 표준 multiprocessing 모듈도 직렬화에 피클을 사용하므로, 큰 데이터를 다른 프로세스로 보낼 때 같은 문제를 겪습니다.

Dask [1], PyArrow [4], IPyParallel [3] 같은 서드파티 Python 라이브러리는 대용량 데이터의 복사를 피한다는 명시적인 목표를 가지고 대안적인 직렬화 방식을 구현하기 시작했습니다. 새로운 직렬화 방식을 구현하는 것은 어렵고, 범용성이 떨어지는 결과로 이어지는 경우가 많습니다(많은 Python 객체가 pickle은 지원하지만 새로운 직렬화 방식은 지원하지 않기 때문입니다). 지원되지 않는 타입에 대해 피클로 대체하는 방법도 있지만, 그렇게 하면 애초에 피하려던 불필요한 메모리 복사가 다시 발생합니다. 예를 들어 dask는 Numpy 배열과 이를 담은 내장 컨테이너(Numpy 배열을 담은 리스트나 딕셔너리 등)에 대해서는 메모리 복사를 피할 수 있지만, 큰 Numpy 배열이 사용자 정의 객체의 속성이라면 dask는 그 사용자 정의 객체를 피클 스트림으로 직렬화하므로 메모리 복사가 발생합니다.

이러한 서드파티 직렬화 시도들의 공통된 방향은 객체 메타데이터 스트림(직렬화되는 객체에 대한 피클과 유사한 정보를 담습니다)과, 큰 객체의 페이로드를 위한 제로 카피 버퍼 객체의 별도 스트림을 생성하는 것입니다. 이 방식에서 정수 등의 작은 객체는 메타데이터 스트림과 함께 덤프될 수 있다는 점에 유의하십시오. dask가 하는 것처럼, 큰 데이터의 타입과 레이아웃에 따라 상황에 맞게 압축하는 식으로 개선할 수도 있습니다.

이 PEP는 큰 데이터를 제로 카피 버퍼의 별도 스트림으로 처리하여 애플리케이션이 그 버퍼들을 최적으로 다룰 수 있도록 하는 방식으로 pickle을 사용할 수 있게 하는 것을 목표로 합니다.

예시

예제를 단순하게 유지하고 서드파티 라이브러리에 대한 지식이 필요하지 않도록, 여기서는 bytearray 객체에 초점을 맞추겠습니다(하지만 Numpy 배열 같은 더 복잡한 객체에서도 개념적으로는 같은 문제입니다). 대부분의 객체와 마찬가지로 bytearray 객체는 pickle 모듈이 곧바로 이해하지 못하므로 자신의 분해 방식을 지정해야 합니다.

현재 bytearray 객체는 피클링을 위해 다음과 같이 분해됩니다:

>>> b.__reduce_ex__(4)
(<class 'bytearray'>, (b'abc',), None)

이는 bytearray.__reduce_ex__ 구현이 개념적으로 다음과 같기 때문입니다:

class bytearray:

   def __reduce_ex__(self, protocol):
      if protocol == 4:
         return type(self), bytes(self), None
      # Legacy code for earlier protocols omitted

결과적으로 다음과 같은 피클 코드가 생성됩니다:

>>> pickletools.dis(pickletools.optimize(pickle.dumps(b, protocol=4)))
    0: \x80 PROTO      4
    2: \x95 FRAME      30
   11: \x8c SHORT_BINUNICODE 'builtins'
   21: \x8c SHORT_BINUNICODE 'bytearray'
   32: \x93 STACK_GLOBAL
   33: C    SHORT_BINBYTES b'abc'
   38: \x85 TUPLE1
   39: R    REDUCE
   40: .    STOP

(위의 pickletools.optimize 호출은 MEMOIZE 옵코드를 제거하여 피클 스트림을 더 읽기 쉽게 만들기 위한 것일 뿐입니다)

bytearray의 페이로드(바이트 시퀀스 b'abc')에 대해 몇 가지 사실을 알 수 있습니다:

  • bytearray.__reduce_ex__는 bytearray의 데이터로 새 bytes 객체를 인스턴스화하면서 첫 번째 사본을 만듭니다.
  • pickle.dumps는 그 bytes 객체의 내용을 피클 스트림의 SHORT_BINBYTES 옵코드 뒤에 삽입할 때 두 번째 사본을 만듭니다.
  • 또한 피클 스트림을 역직렬화할 때 SHORT_BINBYTES 옵코드를 만나면 임시 bytes 객체가 생성됩니다(이 과정에서 데이터 복사가 발생합니다).

우리가 실제로 원하는 것은 다음과 같습니다:

  • bytearray.__reduce_ex__는 bytearray 데이터의 뷰를 만듭니다.
  • pickle.dumps는 그 데이터를 피클 스트림에 복사하려 하지 않고, 대신 버퍼 뷰를 호출자에게 전달합니다(호출자는 그 버퍼를 가장 효율적으로 처리할 방법을 결정할 수 있습니다).
  • 역직렬화할 때 pickle.loads는 피클 스트림과 버퍼 뷰를 따로 받아서 버퍼 뷰를 bytearray 생성자에 직접 전달합니다.

위의 방식이 동작하려면 몇 가지 조건이 필요합니다:

  • __reduce__ 또는 __reduce_ex__는 직렬화할 수 있으며 복사하지 않는 버퍼 뷰를 나타내는 무언가를 반환할 수 있어야 합니다.
  • 피클 프로토콜은 그러한 버퍼 뷰에 대한 참조를 표현할 수 있어야 하며, 실제 버퍼를 대역 외(out of band)에서 가져와야 할 수도 있음을 언피클러에게 알려야 합니다.
  • pickle.Pickler API는 호출자가 직렬화 중에 그러한 버퍼 뷰를 받을 수 있는 방법을 제공해야 합니다.
  • 마찬가지로 pickle.Unpickler API는 호출자가 역직렬화에 필요한 버퍼 뷰를 제공할 수 있도록 해야 합니다.
  • 호환성을 위해 피클 프로토콜은 그러한 버퍼 뷰를 직접 직렬화한 형태도 담을 수 있어야 합니다. 그래야 메모리 복사에 관심이 없는 기존 pickle API 사용 코드를 수정하지 않아도 됩니다.

생산자 API

새로운 타입 pickle.PickleBuffer를 도입합니다. 이 타입은 버퍼를 지원하는 어떤 객체로부터도 인스턴스화할 수 있으며, 특히 __reduce__ 구현에서 반환하도록 고안되었습니다:

class bytearray:

   def __reduce_ex__(self, protocol):
      if protocol >= 5:
         return type(self), (PickleBuffer(self),), None
      # Legacy code for earlier protocols omitted

PickleBuffer는 memoryview의 의미 체계와 기능을 모두 갖추지는 않은 단순한 래퍼이지만, 프로토콜 5 이상이 활성화되어 있으면 pickle 모듈이 이를 특별히 인식합니다. 피클 프로토콜 버전 4 이하로 PickleBuffer를 직렬화하려고 하면 오류가 발생합니다.

pickle 모듈은 PickleBuffer의 원시 데이터만 고려합니다. 모양이나 데이터 타입 같은 타입별 메타데이터는 지금과 마찬가지로 해당 타입의 __reduce__ 구현이 별도로 반환해야 합니다.

PickleBuffer 객체

PickleBuffer 클래스는 매우 단순한 Python API를 지원합니다. 생성자는 PEP 3118 호환 객체 하나를 받습니다. PickleBuffer 객체 자체도 버퍼 프로토콜을 지원하므로, 소비자는 이 객체에 memoryview(...)를 호출하여 하부 버퍼에 대한 추가 정보(원래 타입, 모양 등)를 얻을 수 있습니다. 또한 PickleBuffer 객체에는 다음과 같은 메서드가 있습니다:

raw()

PickleBuffer 하부의 원시 메모리 바이트에 대한 memoryview를 반환하며, 이때 모양, 스트라이드, 포맷 정보는 모두 제거됩니다. 이는 순수 Python 피클 구현에서 Fortran 연속(Fortran-contiguous) 버퍼를 올바르게 처리하는 데 필요합니다.

release()

PickleBuffer의 하부 버퍼를 해제하여 더 이상 사용할 수 없게 합니다.

C 쪽에서는 PickleBuffer 객체를 생성하고 검사하는 간단한 API를 제공할 것입니다:

PyObject *PyPickleBuffer_FromObject(PyObject *obj)

PEP 3118 호환 obj에 대한 뷰를 보유하는 PickleBuffer 객체를 생성합니다.

PyPickleBuffer_Check(PyObject *obj)

obj가 PickleBuffer 인스턴스인지를 반환합니다.

const Py_buffer *PyPickleBuffer_GetBuffer(PyObject *picklebuf)

PickleBuffer 인스턴스가 소유한 내부 Py_buffer에 대한 포인터를 반환합니다. 버퍼가 해제된 경우에는 예외가 발생합니다.

int PyPickleBuffer_Release(PyObject *picklebuf)

PickleBuffer 인스턴스의 하부 버퍼를 해제합니다.

버퍼 요구 사항

PickleBuffer는 비연속 버퍼를 포함한 모든 종류의 버퍼를 감쌀 수 있습니다. 그러나 __reduce__는 연속적인 PickleBuffer만 반환해야 합니다(여기서 연속성은 PEP 3118에서의 의미로, C 순서 또는 포트란 순서 중 하나임을 뜻합니다). 비연속 버퍼를 피클링하면 오류가 발생합니다.

이 제약은 주로 pickle 모듈의 구현을 쉽게 하기 위한 것이지만, 대역 외 버퍼를 사용하는 다른 소비자를 위한 것이기도 합니다. 제공자 측에서 가장 간단한 해결책은 비연속 버퍼의 연속적인 복사본을 반환하는 것입니다. 하지만 정교한 제공자라면 그 대신 연속적인 하위 버퍼의 시퀀스를 반환하기로 할 수도 있습니다.

소비자 API

pickle.Pickler.__init__와 pickle.dumps에 buffer_callback 매개변수가 새로 추가됩니다.:

class Pickler:
   def __init__(self, file, protocol=None, ..., buffer_callback=None):
      """
      If *buffer_callback* is None (the default), buffer views are
      serialized into *file* as part of the pickle stream.

      If *buffer_callback* is not None, then it can be called any number
      of times with a buffer view.  If the callback returns a false value
      (such as None), the given buffer is out-of-band; otherwise the
      buffer is serialized in-band, i.e. inside the pickle stream.

      The callback should arrange to store or transmit out-of-band buffers
      without changing their order.

      It is an error if *buffer_callback* is not None and *protocol* is
      None or smaller than 5.
      """

def pickle.dumps(obj, protocol=None, *, ..., buffer_callback=None):
   """
   See above for *buffer_callback*.
   """

pickle.Unpickler.__init__와 pickle.loads에 buffers 매개변수가 새로 추가됩니다.:

class Unpickler:
   def __init__(file, *, ..., buffers=None):
      """
      If *buffers* is not None, it should be an iterable of buffer-enabled
      objects that is consumed each time the pickle stream references
      an out-of-band buffer view.  Such buffers have been given in order
      to the *buffer_callback* of a Pickler object.

      If *buffers* is None (the default), then the buffers are taken
      from the pickle stream, assuming they are serialized there.
      It is an error for *buffers* to be None if the pickle stream
      was produced with a non-None *buffer_callback*.
      """

def pickle.loads(data, *, ..., buffers=None):
   """
   See above for *buffers*.
   """

프로토콜 변경 사항

새로운 옵코드 세 가지가 도입됩니다:

  • BYTEARRAY8은 피클 스트림에서 그 뒤에 오는 데이터로 bytearray를 만들어 스택에 푸시합니다(BINBYTES8이 bytes 객체에 대해 하는 것과 같습니다);
  • NEXT_BUFFER는 buffers 이터러블에서 버퍼를 가져와 스택에 푸시합니다.
  • READONLY_BUFFER는 스택 최상단의 읽기 전용 뷰를 만듭니다.

피클링 중에 PickleBuffer를 만나면, 다음 조건에 따라 해당 버퍼를 대역 내 또는 대역 외로 간주할 수 있습니다:

  • buffer_callback이 주어지지 않으면 버퍼는 대역 내입니다;
  • buffer_callback이 주어지면 버퍼를 인자로 하여 이를 호출합니다. 콜백이 참 값을 반환하면 버퍼는 대역 내이고, 거짓 값을 반환하면 버퍼는 대역 외입니다.

대역 내 버퍼는 다음과 같이 직렬화됩니다:

  • 버퍼가 쓰기 가능하면, bytearray 객체인 것처럼 피클 스트림에 직렬화됩니다.
  • 버퍼가 읽기 전용이면, bytes 객체인 것처럼 피클 스트림에 직렬화됩니다.

대역 외 버퍼는 다음과 같이 직렬화됩니다:

  • 버퍼가 쓰기 가능하면, 피클 스트림에 NEXT_BUFFER 옵코드가 추가됩니다.
  • 버퍼가 읽기 전용이면, 피클 스트림에 NEXT_BUFFER 옵코드가 추가되고 그 뒤에 READONLY_BUFFER 옵코드가 이어집니다.

읽기 전용 버퍼와 쓰기 가능 버퍼를 구별하는 이유는 아래에서 설명합니다(“가변성” 참고).

부수 효과

인밴드 성능 개선

__reduce_ex__에서 PickleBuffer 인스턴스를 반환하면 직렬화 경로에서 복사를 한 번 피할 수 있으므로, 인밴드 피클링도 개선할 수 있습니다 [10] [12].

주의 사항

가변성

PEP 3118 버퍼는 읽기 전용일 수도 있고 쓰기 가능할 수도 있습니다. Numpy 배열과 같은 일부 객체는 모든 기능을 사용하려면 가변 버퍼를 기반으로 해야 합니다. buffer_callback 및 buffers 인자를 사용하는 피클 소비자는 가변 버퍼를 다시 생성하도록 주의해야 합니다. 이는 I/O를 수행할 때 readinto 같은 버퍼 전달 방식의 API 변형을 사용해야 함을 의미합니다(이러한 변형은 성능 면에서도 더 나은 경우가 많습니다).

데이터 공유

같은 프로세스에서 아웃오브밴드 버퍼 뷰를 전달하면서 객체를 피클한 다음 언피클하면, 언피클된 객체는 원래 피클된 객체와 같은 버퍼를 기반으로 할 수 있습니다.

예를 들어, Numpy 배열의 리덕션을 다음과 같이 구현하는 것이 합리적일 수 있습니다(단순화를 위해 형상(shape)과 같은 핵심 메타데이터는 생략했습니다):

class ndarray:

   def __reduce_ex__(self, protocol):
      if protocol == 5:
         return numpy.frombuffer, (PickleBuffer(self), self.dtype)
      # Legacy code for earlier protocols omitted

그러면 PickleBuffer를 dumps에서 loads로 전달하기만 해도, 원래 Numpy 객체와 같은 기반 메모리를 공유하는 새 Numpy 배열이 만들어집니다(그리고 부수적으로 원래 객체를 살아 있게 유지합니다):

>>> import numpy as np
>>> a = np.zeros(10)
>>> a[0]
0.0
>>> buffers = []
>>> data = pickle.dumps(a, protocol=5, buffer_callback=buffers.append)
>>> b = pickle.loads(data, buffers=buffers)
>>> b[0] = 42
>>> a[0]
42.0

기존 pickle API를 사용할 때(즉, buffers 및 buffer_callback 매개변수를 전달하지 않을 때)는 이런 일이 일어나지 않습니다. 이 경우 버퍼 뷰가 복사되어 피클 스트림 안에 직렬화되기 때문입니다.

기각된 대안

기존 영속적 로드 인터페이스 사용

pickle 영속성 인터페이스는 지정된 객체에 대한 참조를 피클 스트림에 저장하고, 그 객체의 실제 직렬화는 아웃오브밴드로 처리하는 방법입니다. 예를 들어, bytearray의 제로 카피 직렬화를 위해 다음과 같은 방법을 고려해 볼 수 있습니다:

class MyPickle(pickle.Pickler):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.buffers = []

    def persistent_id(self, obj):
        if type(obj) is not bytearray:
            return None
        else:
            index = len(self.buffers)
            self.buffers.append(obj)
            return ('bytearray', index)


class MyUnpickle(pickle.Unpickler):

    def __init__(self, *args, buffers, **kwargs):
        super().__init__(*args, **kwargs)
        self.buffers = buffers

    def persistent_load(self, pid):
        type_tag, index = pid
        if type_tag == 'bytearray':
            return self.buffers[index]
        else:
            assert 0  # unexpected type

이 메커니즘에는 두 가지 단점이 있습니다:

  • 각 pickle 소비자는 관심 있는 타입마다 사용자 정의 코드를 작성하여 Pickler 및 Unpickler 서브클래스를 다시 구현해야 합니다. 결국 N개의 피클 소비자가 각각 M개의 생산자를 위한 사용자 정의 코드를 구현하게 됩니다. 이는 어렵고(특히 Numpy 배열과 같은 복잡한 타입의 경우) 확장성도 떨어집니다.
  • 피클 모듈이 만나는 모든 객체(정수나 문자열 같은 단순한 내장 객체까지 포함)가 사용자의 persistent_id() 메서드 호출을 유발하므로, 평상시보다 성능이 떨어질 수 있습니다.

    (Python 2의 cPickle 모듈은 내장 타입이 아닌 타입에 대해서만 호출되는, 문서화되지 않은 inst_persistent_id() 훅을 지원했습니다. 이 훅은 persistent_id를 호출하는 데 따른 성능 문제를 완화하기 위해 1997년에 추가되었으며, 아마도 ZODB의 요청에 따른 것으로 보입니다)

buffer_callback에 버퍼 시퀀스 전달

단일 버퍼 대신 버퍼 시퀀스를 전달하면, 직렬화 중에 많은 수의 버퍼가 생성되는 경우 함수 호출 오버헤드를 줄일 수 있을 것입니다. 이를 위해서는 콜백을 호출하기 전에 버퍼를 저장하도록 Pickler에 추가 지원이 필요합니다. 그러나 이렇게 하면 버퍼 콜백이 버퍼를 인밴드로 직렬화할지 아웃오브밴드로 직렬화할지를 나타내는 불리언 값을 반환할 수도 없게 됩니다.

직렬화할 버퍼가 많은 경우는 드물다고 판단하여, 버퍼 콜백에 단일 버퍼를 전달하기로 결정했습니다.

프로토콜 4 이하에서 PickleBuffer 직렬화 허용

프로토콜 4 이하에서 PickleBuffer 직렬화를 허용한다면, 버퍼가 가변일 때 실제로는 메모리 복사가 한 번 더 일어납니다. 실제로 가변 PickleBuffer는 해당 프로토콜에서 bytearray 객체로 직렬화되며(첫 번째 복사), 그 bytearray 객체를 직렬화하면 bytes 객체를 반환하는 bytearray.__reduce_ex__가 호출됩니다(두 번째 복사).

__reduce__ 구현자가 의도치 않게 성능 저하를 일으키지 않도록, 프로토콜이 5보다 작을 때는 PickleBuffer를 거부하기로 결정했습니다. 이에 따라 구현자는 __reduce_ex__로 전환하여 프로토콜에 따라 달라지는 직렬화를 구현해야 하며, 각 프로토콜에 가장 적합한 경로를 활용하게 됩니다(또는 적어도 프로토콜 5 이상을 프로토콜 4 이하와 별도로 처리하게 됩니다).

구현

이 PEP는 처음에 저자의 GitHub 포크에서 구현되었습니다 [6]. 이후 Python 3.8에 병합되었습니다 [7].

Python 3.6 및 3.7용 백포트는 PyPI에서 내려받을 수 있습니다 [8].

Numpy에 pickle 프로토콜 5와 대역 외(out-of-band) 버퍼 지원이 추가되었습니다 [11].

Apache Arrow Python 바인딩에 pickle 프로토콜 5와 대역 외(out-of-band) 버퍼 지원이 추가되었습니다 [9].

관련 작업

Dask.distributed는 pickle로 대체(fallback)하는 기능을 갖춘 사용자 정의 제로 카피 직렬화를 구현합니다 [2].

PyArrow는 선택된 몇 가지 타입에 대해 제로 카피 컴포넌트 기반 직렬화를 구현합니다 [5].

PEP 554는 하나의 프로세스에서 여러 인터프리터를 호스팅하는 것을 제안하며, 통신 방식으로서 인터프리터 간에 버퍼를 전달하는 방안을 포함합니다.

감사의 글

초기 피드백을 주신 다음 분들께 감사드립니다: Alyssa Coghlan, Olivier Grisel, Stefan Krah, MinRK, Matt Rocklin, Eric Snow.

구현을 실험해 주신 Pierre Glaser와 Olivier Grisel에게 감사드립니다.

참고 문헌