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를 사용하지 않는 경우 동작이 변경되지 않음을 보장합니다.

근거

피클 프로토콜은 임의의 Python 객체를 디스크에 영속화하기 위해 1995년에 처음 설계되었습니다. 1995년 당시 저장 매체의 성능을 고려하면, 데이터를 디스크에 쓰기 전에 임시 데이터를 복사할 때 RAM 대역폭을 사용하는 것과 같은 성능 지표에 집중하는 것은 아마도 중요하지 않았을 것입니다.

오늘날 피클 프로토콜은 대부분의 데이터가 디스크에 영속화되지 않거나, 영속화되는 경우에도 Python 전용 형식 대신 이식 가능한 형식을 사용하는 애플리케이션에서 점점 더 많이 사용되고 있습니다. 대신 피클은 동일한 머신 또는 여러 머신에서 한 프로세스의 데이터와 명령을 다른 프로세스로 전송하는 데 사용되고 있습니다. 이러한 애플리케이션은 때때로 전송해야 하는 매우 큰 데이터(예: Numpy 배열 또는 Pandas 데이터프레임)를 처리합니다. 이러한 애플리케이션에서 피클은 직렬화되는 데이터에 불필요한 메모리 복사를 발생시키므로 현재 비효율적입니다.

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

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

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

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

예제

예제를 단순하게 유지하고 서드파티 라이브러리에 대한 지식을 요구하지 않기 위해, 여기서는 bytearray 객체에 초점을 맞춥니다(그러나 이 문제는 Numpy 배열과 같은 더 복잡한 객체에서도 개념적으로 동일합니다). 대부분의 객체와 마찬가지로 bytearray 객체는 피클 모듈이 즉시 이해할 수 없으므로, 그 분해 방식을 지정해야 합니다.

다음은 현재 피클링할 때 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 opcode 뒤의 pickle 스트림에 삽입할 때 두 번째 복사본을 생성합니다.
  • 또한 pickle 스트림을 역직렬화할 때 SHORT_BINBYTES opcode를 만나면 임시 bytes 객체가 생성되어 데이터 복사가 발생합니다.

실제로 원하는 것은 다음과 같은 것입니다.

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

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

  • __reduce__ 또는 __reduce_ex__는 직렬화 가능한 복사 없는 버퍼 뷰를 나타내는 무언가를 반환할 수 있어야 합니다.
  • pickle 프로토콜은 이러한 버퍼 뷰에 대한 참조를 표현할 수 있어야 하며, 언피클러에 실제 버퍼를 대역 외부에서 가져와야 할 수도 있음을 지시해야 합니다.
  • pickle.Pickler API는 직렬화하는 동안 이러한 버퍼 뷰를 받을 방법을 호출자에게 제공해야 합니다.
  • pickle.Unpickler API도 마찬가지로 역직렬화에 필요한 버퍼 뷰를 호출자가 제공할 수 있도록 해야 합니다.
  • 호환성을 위해 pickle 프로토콜은 이러한 버퍼 뷰를 직접 직렬화한 내용을 포함할 수도 있어야 하며, 메모리 복사와 관련이 없는 경우에는 현재 pickle API 사용을 수정할 필요가 없어야 합니다.

생산자 API

모든 버퍼 지원 객체에서 인스턴스화할 수 있고 __reduce__ 구현에서 반환되도록 특별히 고안된 새 형식 pickle.PickleBuffer를 도입합니다.:

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 모듈에서 특별히 인식됩니다. pickle 프로토콜 버전 4 이하로 PickleBuffer를 직렬화하려고 하면 오류가 발생합니다.

pickle 모듈은 PickleBuffer의 원시 데이터만 고려합니다. 형식별 메타데이터(예: shape 또는 데이터 형식)는 이미 그러한 경우와 마찬가지로 해당 형식의 __reduce__ 구현에서 별도로 반환해야 합니다.

PickleBuffer 객체

PickleBuffer 클래스는 매우 단순한 Python API를 지원합니다. 생성자는 하나의 PEP 3118 호환 객체를 받습니다. PickleBuffer 객체 자체는 버퍼 프로토콜을 지원하므로, 소비자는 memoryview(...)를 호출하여 기반 버퍼에 대한 추가 정보(예: 원래 형식, shape 등)를 얻을 수 있습니다. 또한 PickleBuffer 객체에는 다음 메서드가 있습니다.

raw()

PickleBuffer의 기반이 되는 원시 메모리 바이트의 memoryview를 반환하며, shape, stride 및 형식 정보를 제거합니다. 이는 순수 Python pickle 구현에서 Fortran 연속 버퍼를 올바르게 처리하는 데 필요합니다.

release()

PickleBuffer의 기반 버퍼를 해제하여 사용할 수 없게 만듭니다.

C 측에서는 PickleBuffer 객체를 생성하고 검사할 수 있는 간단한 API가 제공됩니다.

PyObject *PyPickleBuffer_FromObject(PyObject *obj)

obj에 대한 PEP 3118 호환 뷰를 보유하는 PickleBuffer 객체를 생성하십시오.

PyPickleBuffer_Check(PyObject *obj)

objPickleBuffer 인스턴스인지 여부를 반환합니다.

const Py_buffer *PyPickleBuffer_GetBuffer(PyObject *picklebuf)

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

int PyPickleBuffer_Release(PyObject *picklebuf)

PickleBuffer 인스턴스의 기반 버퍼를 해제합니다.

버퍼 요구 사항

PickleBuffer는 비연속 버퍼를 포함한 모든 종류의 버퍼를 래핑할 수 있습니다. 그러나 __reduce__는 연속 PickleBuffer만 반환해야 합니다(contiguityPEP 3118 의미로 사용되며, C 순서 또는 Fortran 순서 중 하나를 뜻합니다). 비연속 버퍼는 피클링할 때 오류를 발생시킵니다.

이 제한은 주로 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은 피클 스트림에서 그 뒤에 오는 데이터로부터 바이트 배열을 생성하여 스택에 푸시합니다(바이트 객체에 대해 BINBYTES8이 수행하는 것과 같습니다).
  • NEXT_BUFFERbuffers 이터러블에서 버퍼를 가져와 스택에 푸시합니다.
  • READONLY_BUFFER는 스택 최상단의 읽기 전용 뷰를 생성합니다.

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

  • buffer_callback이 제공되지 않으면 버퍼는 인밴드입니다.
  • buffer_callback이 제공되면 버퍼를 인자로 하여 호출됩니다. 콜백이 참인 값을 반환하면 버퍼는 인밴드이고, 거짓인 값을 반환하면 버퍼는 대역 외입니다.

인밴드 버퍼는 다음과 같이 직렬화됩니다.

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

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

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

읽기 전용 버퍼와 쓰기 가능한 버퍼를 구분하는 이유는 아래의 “Mutability”에서 설명합니다.

부수 효과

인밴드 성능 개선

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

주의 사항

변경 가능성

PEP 3118 버퍼는 읽기 전용이거나 쓰기 가능할 수 있습니다. Numpy 배열과 같은 일부 객체는 완전하게 작동하려면 변경 가능한 버퍼를 기반으로 해야 합니다. buffer_callbackbuffers 인자를 사용하는 피클 소비자는 변경 가능한 버퍼를 다시 생성할 때 주의해야 합니다. I/O를 수행할 때 이는 readinto와 같은 버퍼 전달 API 변형을 사용해야 함을 의미합니다(이러한 변형은 성능 측면에서도 흔히 더 선호됩니다).

데이터 공유

동일한 프로세스에서 객체를 피클한 다음 언피클하고 대역 외 버퍼 뷰를 전달하면, 언피클된 객체가 원래 피클된 객체와 동일한 버퍼를 기반으로 할 수 있습니다.

예를 들어, 다음과 같이 Numpy 배열의 리덕션을 구현하는 것이 합리적일 수 있습니다(단순화를 위해 셰이프와 같은 중요한 메타데이터는 생략합니다).:

class ndarray:

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

그런 다음 dumps에서 loads로 PickleBuffer를 단순히 전달하면 원래 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에서는 이런 일이 발생하지 않습니다(즉, buffersbuffer_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 소비자는 관심 있는 각 유형에 대한 사용자 지정 코드를 포함하여 PicklerUnpickler 서브클래스를 다시 구현해야 합니다. 본질적으로 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 객체를 직렬화하면 bytearray.__reduce_ex__를 호출하여 bytes 객체를 반환하므로(두 번째 복사본) 그렇습니다.

__reduce__ 구현자들이 의도치 않은 성능 저하를 초래하지 않도록 하기 위해, 프로토콜이 5보다 작은 경우 PickleBuffer를 거부하기로 결정했습니다. 이는 구현자들이 __reduce_ex__로 전환하여 프로토콜에 따라 다른 직렬화를 구현하도록 강제하며, 각 프로토콜에 최적의 경로를 활용하게 합니다(또는 최소한 프로토콜 5 이상을 프로토콜 4 이하와 별도로 처리하게 합니다).

구현

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

Python 3.6과 3.7을 위한 백포트는 PyPI에서 다운로드할 수 있습니다 [8].

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

pickle 프로토콜 5와 대역 외 버퍼에 대한 지원이 Apache Arrow Python 바인딩에 추가되었습니다 [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에게 감사드립니다.

참고 문헌