|
| 1 | +# 74. bytes 를 복사하지 않고 다루려면 memoryview와 bytearray를 사용하라 |
| 2 | + |
| 3 | +## 1. 파이선에서의 적절한 I/O 지원 선택 중요성 |
| 4 | + |
| 5 | +- 파이선이 CPU 위주의 계산 작업을 추가적인 노력없이 병렬화해줄 수는 없으나 스루풋이 높은 병렬 I/O 를 다양한 방식으로 지원 가능 |
| 6 | + - I/O 지원을 잘못 사용하여 파이선이 I/O 위주의 부하에 대해서도 느리다는 결론으로 이어짐 |
| 7 | + |
| 8 | +- 네트워크를 통해 스트리밍하는 미디어 서버 예 |
| 9 | + - 플레이 중인 비디오 앞, 뒤로 이동 기능 |
| 10 | + - 반복 기능 |
| 11 | + |
| 12 | +```python |
| 13 | +def timecode_to_index(video_id, timecode): |
| 14 | + ... |
| 15 | + # 비디오 데이터의 바이트 오프셋을 반환한다 |
| 16 | + |
| 17 | +def request_chunk(video_id, byte_offset, size): |
| 18 | + ... |
| 19 | + # video_id에 대한 비디오 데이터 중에서 바이트 오프셋부터# size만큼을 반환한다 |
| 20 | + |
| 21 | +video_id = ... |
| 22 | +timecode = '01:09:14:28 |
| 23 | +byte_offset = timecode_to_index(video_id, timecode) |
| 24 | +size = 20 * 1024 * 1024 |
| 25 | +video_data = request_chunk(video_id, byte_offset, size) |
| 26 | +``` |
| 27 | + |
| 28 | +- `request_chunk` 요청을 받아서 요청에 해당하는 데이터를 도렬주는 서버 측 핸들러 구현 |
| 29 | + - 요청 받은 데이터 덩어리를 메모리 캐시에 들어 있는 수 기가바이트 크기의 비디오 정보에서 꺼낸 후 소켓을 통해 클라이언트에게 돌려주는 과정에 집중 |
| 30 | +- 해당 코드의 지연 시간과 스루풋 |
| 31 | + 1. `video_data` 에서 20MB 의 비디오 덩어리를 가져오는 데 걸리는 시간 |
| 32 | + 2. 데이터를 클라이언트에게 송신하는 데 걸리는 시간 |
| 33 | + - 2번이 무한히 빠르다고 가정하면 1번에만 집중 |
| 34 | + - 데이터 덩어리를 만들기 위해 bytes 인스턴스를 슬라이싱하는 방법 시간 측정으로 확인 가능 |
| 35 | + |
| 36 | +```python |
| 37 | +socket = ... # 클라이언트가 연결한 소켓 |
| 38 | +video_data = ... # video_id에 해당하는 데이터가 들어 있는# bytes |
| 39 | +byte_offset = ... # 요청받은 시작 위치 |
| 40 | +size = 20 * 1024 * 1024 # 요청받은 데이터 크기 |
| 41 | + |
| 42 | +chunk = video_data[byte_offset:byte_offset + size] |
| 43 | +socket.send(chunk) |
| 44 | +``` |
| 45 | + |
| 46 | +- 대략 5밀리초 |
| 47 | +- 서버의 최대 스루풋 |
| 48 | + - 이론적으로 20MB / 5밀리초 = 7.3GB |
| 49 | +- 병렬로 새 데이터 덩어리를 요청할 수 있는 클라이언트의 최대 개수 |
| 50 | + - 1 CPU 초 / 5밀리초 = 200 |
| 51 | +- **문제** |
| 52 | + - 기반 데이터를 `bytes` 인스턴스로 슬라이싱하려면 메모리를 복사해야 하는데 이 과정이 CPU 시간 점유 |
| 53 | + |
| 54 | +```python |
| 55 | +import timeit |
| 56 | + |
| 57 | +def run_test(): |
| 58 | + chunk = video_data[byte_offset:byte_offset + size] |
| 59 | + # socket.send(chunk)를 호출해야 하지만 벤치마크를 위해 무시한다 |
| 60 | + |
| 61 | +result = timeit.timeit( |
| 62 | + stmt='run_test()', |
| 63 | + globals=globals(), |
| 64 | + number=100) / 100 |
| 65 | + |
| 66 | +print(f'{result:0.9f} 초') |
| 67 | + |
| 68 | +>>> |
| 69 | +0.004925669 초 |
| 70 | +``` |
| 71 | + |
| 72 | +## 2. `memoryview` 내장 타입 사용 |
| 73 | + |
| 74 | +- CPython 의 고성능 버퍼 프로토콜을 프로그램에 노출 |
| 75 | +- 버퍼 프로토콜은 파이선 런타임과 C 확장이 bytes 와 같은 객체를 통하지 않고 하부 데이터 버퍼에 접근할 수 있게 해주는 저수준 C API |
| 76 | +- 슬라이싱에서 데이터를 복사하지 않고 새로운 `memoryview` 인스턴스를 생성함 |
| 77 | + |
| 78 | +```python |
| 79 | +data = '동해물과 abc 백두산이 마르고 닳도록'.encode("utf8") |
| 80 | +view = memoryview(data) |
| 81 | +chunk = view[12:19] |
| 82 | +print(chunk) |
| 83 | +print('크기:', chunk.nbytes) |
| 84 | +print('뷰의 데이터:', chunk.tobytes()) |
| 85 | +print('내부 데이터:', chunk.obj) |
| 86 | + |
| 87 | +>>> |
| 88 | +<memory at 0x000002245F779D00> |
| 89 | +크기: 7 |
| 90 | +뷰의 데이터: b' abc \xeb\xb0' |
| 91 | +내부 데이터: b'\xeb\x8f\x99\xed\x95\xb4\xeb\xac\xbc\xea\xb3\xbc abc\xeb\xb0\xb1\xeb\x91\x90\xec\x82\xb0\xec\x9d\xb4 \xeb\xa7\x88\xeb\xa5\xb4\xea\xb3\xa0 \xeb\x8b\xb3\xeb\x8f\x84\xeb\xa1\x9d |
| 92 | +``` |
| 93 | + |
| 94 | +- zero-copy 연산을 활성화하여 NumPy 같은 수치 계산 C 확장이나 예제 프로그램 같은 I/O 위주 프로그램이 커다란 메모리를 빠르게 처리해야 할 경우 성능 향상 가능 |
| 95 | + |
| 96 | +- `bytes` 슬라이스를 `memoryview` 로 변경한 후 벤치마크 수행한 결과 |
| 97 | + |
| 98 | +```python |
| 99 | +video_view = memoryview(video_data) |
| 100 | +def run_test(): |
| 101 | + chunk = video_view[byte_offset:byte_offset + size] |
| 102 | + # socket.send(chunk)를 호출해야 하지만 벤치마크를 위해 무시한다 |
| 103 | + |
| 104 | +result = timeit.timeit( |
| 105 | + stmt='run_test()', |
| 106 | + globals=globals(), |
| 107 | + number=100) / 100 |
| 108 | + |
| 109 | +print(f'{result:0.9f} 초') |
| 110 | + |
| 111 | +>>> |
| 112 | +0.000000250 초 |
| 113 | +``` |
| 114 | + |
| 115 | +- 성능 개선으로 인해 프로그램의 성능은 CPU bound 보단 소켓 연결 성능에 따라 제한 |
| 116 | + |
| 117 | +- 일부 클라이언트가 여러 사용자에게 방송하기 위해 서버러 라이브 비디오 스트림 보내는 예 |
| 118 | + - 사용자가 가장 최근에 보낸 비디오 데이터를 캐시에 삽입, 다른 클라이언트가 캐시에 있는 비디오 데이터를 읽도록 해야 함 |
| 119 | +- `socket.recv` 메소드는 bytes 인스턴스 반환 |
| 120 | +- 슬라이스 연산과 `bytes.join` 메소드를 사용하여 `byte_offset` 에 있는 기존 캐시 데이터를 새로운 데이터로 splice 가능 |
| 121 | +- 벤치마크 수행 |
| 122 | + - 1MB 데이터를 받아 비디오 캐시를 갱신하는데 걸린 시간 |
| 123 | + - 33밀리초 |
| 124 | + - 수신 시 최대 스루풋 |
| 125 | + - 1MB / 33밀리초 = 31MB/s |
| 126 | + - 스트리밍 방송 클라이언트는 31개로 제한 |
| 127 | + |
| 128 | +```python |
| 129 | +socket = ... # 클라이언트가 연결한 소켓 |
| 130 | +video_cache = ... # 서버로 들어오는 비디오 스트림의 캐시 |
| 131 | +byte_offset = ... # 데이터 버퍼 위치 |
| 132 | +size = 1024 * 1024 # 데이터 덩어리 크기 |
| 133 | +chunk = socket.recv(size) |
| 134 | +video_view = memoryview(video_cache) |
| 135 | +before = video_view[:byte_offset] |
| 136 | +after = video_view[byte_offset + size:] |
| 137 | +new_cache = b''.join([before, chunk, after]) |
| 138 | +``` |
| 139 | + |
| 140 | +```python |
| 141 | +def run_test(): |
| 142 | + chunk = socket.recv(size) |
| 143 | + before = video_view[:byte_offset] |
| 144 | + after = video_view[byte_offset + size:] |
| 145 | + new_cache = b''.join([before, chunk, after]) |
| 146 | + |
| 147 | +result = timeit.timeit( |
| 148 | + stmt='run_test()', |
| 149 | + globals=globals(), |
| 150 | + number=100) / 100 |
| 151 | + |
| 152 | +print(f'{result:0.9f} 초') |
| 153 | + |
| 154 | +>>> |
| 155 | +0.033520550 초 |
| 156 | +``` |
| 157 | + |
| 158 | +- `bytes` 인스턴스는 read-only 이므로 인덱스를 사용해 값을 변경할 수 없으므로 `bytearrary` 사용 |
| 159 | +- `bytearray` |
| 160 | + - `bytes` 에서 원하는 위치에 있는 값 변경 가능한 가변 버전 |
| 161 | + |
| 162 | +```python |
| 163 | +my_bytes = b'hello' |
| 164 | +my_bytes[0] = b'\x79' |
| 165 | + |
| 166 | +>>> |
| 167 | +Traceback ... |
| 168 | +TypeError: 'bytes' object does not support item assignment |
| 169 | +``` |
| 170 | + |
| 171 | +```python |
| 172 | +my_array = bytearray('hello 안녕'.encode("utf8")) # b''가 아니라# '' 문자열 |
| 173 | +my_array[0] = 0x79 |
| 174 | +print(my_array) |
| 175 | + |
| 176 | +>>> |
| 177 | +bytearray(b'yello \xec\x95\x88\xeb\x85\x95') |
| 178 | +``` |
| 179 | + |
| 180 | +- `memoryview` 를 이용한 개선책 |
| 181 | + - `socket.recv_info` 나 `RawIOBase.readInto` 와 같은 라이브러리 메소드가 버퍼 프로토콜을 사용해 데이터를 빠르게 받아들이거나 읽을 수 있음 |
| 182 | + |
| 183 | +```python |
| 184 | +video_array = bytearray(video_cache) |
| 185 | +write_view = memoryview(video_array) |
| 186 | +chunk = write_view[byte_offset:byte_offset + size] |
| 187 | + |
| 188 | +socket.recv_into(chunk) |
| 189 | +``` |
| 190 | + |
| 191 | +- 벤치마크 수행 결과 |
| 192 | + |
| 193 | +```python |
| 194 | +def run_test(): |
| 195 | + chunk = write_view[byte_offset:byte_offset + size] |
| 196 | + socket.recv_into(chunk) |
| 197 | + |
| 198 | +result = timeit.timeit( |
| 199 | + stmt='run_test()', |
| 200 | + globals=globals(), |
| 201 | + number=100) / 100 |
| 202 | + |
| 203 | +print(f'{result:0.9f} 초') |
| 204 | + |
| 205 | +>>> |
| 206 | +0.000033925 초 |
| 207 | +``` |
0 commit comments