Python의 배열-숫자 데이터를 효율적으로 사용하기 II

2024. 1. 31. 18:50python/intermediate

기존 어레이를 프로토타입으로 사용m

특별한 경우로, 다른 Python 배열을 초기화 값으로 제공하여 해당 요소를 복사할 수 있습니다. 결국 배열은 생성자에 전달할 수 있는 숫자의 반복 가능한 것일 뿐입니다.

In [ ]:
from array import array
In [ ]:
original = array("i", [1, 2, 3])
cloned = array("i", original)
cloned == original
In [ ]:
cloned is original

먼저 세 개의 정수로 구성된 배열을 만든 다음 이를 새 배열에 공급합니다. 복제된 배열은 원본 프로토타입과 동일한 값으로 구성되지만 메모리에는 별도의 엔터티로 존재합니다. 두 배열은 서로 다른 개체이면서 동일한 것으로 비교되므로 서로 독립적으로 수정할 수 있습니다.

참고:단지 배열의 복사본을 원하는 경우 배열이 메모리 최적화된 특수 방법을 통해 지원하는 copy 모듈을 사용하는 것이 더 좋습니다 .

두 배열 모두 동일한 유형의 요소를 포함합니다. 대상 배열이 입력 배열의 상위 집합 유형을 선언하는 한, 또는 더 일반적으로 숫자가 사용 가능한 값 범위에 편안하게 들어갈 수 있는 경우 배열을 초기화할 수 있습니다.

In [ ]:
array("B", array("Q", range(256)))

여기서는 요소가 숫자당 최소 8배 더 많은 메모리를 사용하는 다른 배열을 사용하여 부호 없는 바이트 배열을 초기화합니다! 그러나 실제 값은 대상 데이터 유형에 맞을 정도로 작기 때문에 문제가 없습니다.

정수 배열을 새로운 부동 소수점 숫자 배열에 입력하면 흥미로운 일이 발생합니다.

In [ ]:
array("f", array("i", [1, 2, 3]))

단정밀도 및 배정밀도 부동 소수점은 입력 유형의 상위 집합이므로 Python은 자동으로 숫자를 더 넓은 유형으로 승격시킵니다. 부작용으로 부동 소수점 숫자 배열을 초기화할 때 정수와 부동 소수점을 자유롭게 혼합할 수 있습니다. Python은 결국 이를 적절한 표현으로 변환하기 때문입니다.

In [ ]:
array("d", (1, 2.8, 4, 5.6))

입력 튜플은 Python 정수와 부동 소수점으로 구성되며, 모두 결국 배정밀도 부동 소수점 숫자로 변환됩니다.

그러나 이 유형 변환은 부동 소수점 숫자의 소수 부분에 대한 잠재적인 정보 손실을 초래할 수 있으므로 반대 방향으로는 작동하지 않습니다.

In [ ]:
array("i", array("f", [3.14, 2.72]))

부동 소수점 숫자로 정수 배열을 초기화하려는 시도가 실패합니다. Python 배열을 잘못 사용하면 문제가 발생할 수 있는 일이 더 많이 있으므로 다음에는 이러한 문제에 대처하는 방법을 배우게 됩니다.

배열 생성 시 일반적인 함정 방지

초기화 매개변수나 곧 배우게 될 인터페이스를 통해 배열에 추가하는 모든 요소는 배열의 선언된 유형 코드와 호환되어야 한다는 점을 기억하세요. 그렇지 않으면 많은 문제에 노출될 것입니다.

우선, 문자 대신 복소수 또는 Python 문자열의 반복 가능 항목과 같이 지원되지 않는 데이터 유형의 값을 포함하는 초기화 프로그램을 제공하면 TypeError를 얻게 됩니다.

In [ ]:
array("d", [3 + 2j, 3 - 2j])
In [ ]:
array("u", ["café", "tea"])

첫 번째 경우에는 배정밀도 부동 소수점 숫자의 배열을 선언했지만 배열이 지원하는 숫자 유형에 속하지 않는 복소수로 구성된 목록을 전달했습니다. 그런 다음 다중 문자 Python 문자열 목록을 사용하여 유니코드 문자 배열을 초기화하려고 했습니다.

그런데 유형 "u"코드를 사용할 때 전체 문자열이나 개별 문자 목록과 같은 반복 가능한 문자만 제공할 수 있습니다. 다른 모든 유형 코드의 경우 Python은 TypeError를 다시 발생시킵니다.

In [ ]:
array("i", "café")
In [ ]:
array("i", ["c", "a", "f", "é"])

가장 일반적인 오류는 숫자 중 하나 이상이 지원되는 값 범위를 벗어날 때 발생하는 정수 오버플 로입니다.

In [ ]:
array("B", [-273, 0, 100])
In [ ]:
array("b", [55, 89, 144])

부호 없는 바이트는 음수가 될 수 없기 때문에 배열 생성자를 처음 호출하면 언더플로가 발생합니다. 반대로 두 번째 호출에서는 부호 있는 바이트의 최대값이 127이므로 오버플로가 발생 합니다 .

배열의 데이터 유형에서 표현할 수 있는 최대값을 초과하는 숫자를 우연히 발견하면 관련 문제가 발생할 수 있습니다.

In [ ]:
array("i", [10 ** 309])
In [ ]:
array("Q", [10 ** 309])
In [ ]:
array("d", [10 ** 309])
In [ ]:
array("d", [1e309])

첫 번째 경우, Python의 정수 10 309 는 C long 데이터 유형으로 표현 가능한 최대값을 초과합니다. 두 번째 경우에는 이렇게 많은 수를 수용할 수 있는 해당 C 데이터 유형이 존재하지 않습니다. 다음으로, 큰 정수가 포함된 Python의 list를 사용하여 부동 소수점 숫자 배열을 초기화해 봅니다 .

지수 연산자( )를 사용하여 정수 값을 계산하는 것과 동일한 값을 나타내기 위해 **해당 부동 소수점 리터럴 1e309를 지정하는 것의 차이점을 확인하세요. 부동 소수점 숫자는 제한되어 있기 때문에 Python은 자동으로 리터럴을 무한대를 나타내는 특수 값으로 반올림합니다. 반면에 매우 큰 값 int을 float로 전환하려고 시도하는 동안에 OverflowError를 발생시킵니다.

이제 적절한 유형의 Python 배열을 초기화할 때 정수와 부동 소수점 숫자를 혼합할 수 있다는 것을 알았습니다. 그러나 단정밀도 부동 소수점 배열을 정의할 때 발생할 수 있는 정밀도 손실 에 주의하세요.

In [ ]:
array("f", (1.4, 2.8, 4, 5.6))

사용할 수 있는 비트 수가 적으면 이러한 값 중 일부를 정확하게 표현할 수 없습니다. 부동 소수점 표현 오류 로 인해 표현 가능한 가장 가까운 숫자로 반올림될 수 있습니다.

또한 멀티바이트 숫자로 이동할 경우 초기화 값으로 전달된 바이트 시퀀스가 ​​지정된 요소 크기의 배수인지 확인해야 합니다.

In [ ]:
array("H", b"\xff\x00\x80")

이 경우 각각 2바이트 길이의 부호 없는 short 배열을 3바이트 시퀀스로 초기화하려고 했습니다. 초기화 프로그램에서 1바이트가 누락되었기 때문에 Python은 적절한 메시지와 함께 ValueError를 발생시킵니다.

이제 Python에서 배열로 작업할 때 이러한 일반적인 함정에 빠지지 않도록 방지할 수 있는 지식을 갖추게 되었습니다.

Python 및 그 이상에서 배열 사용

이 섹션에서는 Python에서 배열을 사용하는 연습을 하게 됩니다. 그 과정에서 배열이 Python 목록과 많이 유사하지만 동시에 배열에는 특별한 것이 있다는 것을 알게 될 것입니다. 배열이 다른 데이터 유형과 작동하도록 만드는 방법을 배우고 네이티브 C 라이브러리에서 배열에 액세스할 수도 있습니다!

배열을 가변 시퀀스로 조작

array 클래스 는 상당히 낮은 수준이고 Python에서 자주 사용되지 않지만 더 널리 사용되는 list와 tuple 데이터 유형과 함께 본격적인 시퀀스 유형 입니다. 결과적으로 시퀀스나 반복 가능 항목이 필요할 때마다 배열을 사용할 수 있습니다. 예를 들어, 피보나치수열 의 배열을 관련 함수에 전달할 수 있습니다.

In [ ]:
from array import array
fibonacci_numbers = array("I", [1, 1, 2, 3, 5, 8, 13, 21, 34, 55])
len(fibonacci_numbers)
In [ ]:
sum(fibonacci_numbers)

내장 len()함수는 시퀀스를 인수로 기대하는 반면, sum()배열은 확실히 자격이 있는 숫자의 반복 가능을 사용합니다. Python의 덕 타이핑 덕분에 현재 객체가 주어진 컨텍스트에서 예상대로 작동하는 한 정확한 데이터 유형은 중요하지 않습니다.

당연히 유한 시퀀스이므로 배열을 반복할 수 있습니다. 따라서 원하는 경우 요소를 반복하거나 수동 순회를 위해 반복자 객체를 반환하도록 배열에 요청할 수 있습니다.

In [ ]:
for i, number in enumerate(fibonacci_numbers):
    print(f"[{i}] = {number:>2}")

위의 예에서는 먼저 enumerate() 함수를 for와 함께 루프를 사용하여 피보나치 수열의 각 요소를 방문한 다음 해당 인덱스와 값을 인쇄합니다. 다음으로 임시 반복자 개체를 만들고 내장 next() 함수를 사용하여 한 번 진행합니다.

대체로 Python 배열의 인터페이스는 와 유사하므로 list마치 집처럼 편안하게 느껴질 것입니다.

In [ ]:
fibonacci_numbers[-1]
In [ ]:
fibonacci_numbers[2:9:3]
In [ ]:
fibonacci_numbers + array("I", [89, 144])
In [ ]:
3 * array("I", [89, 144])
In [ ]:
42 in fibonacci_numbers

배열을 인덱싱 하고 분할하는 데 대괄호 구문을 사용할 수 있을 뿐만 아니라 연결 연산자(+) , 반복 연산자(*), 소속 테스트 연산자(in, not in) 등 친숙한 연산자도 사용할 수 있습니다 . 또한 .count() 및 .index() 배열과 같은 대부분의 Python list 메서드를 찾을 수 있습니다.

In [ ]:
fibonacci_numbers.count(1)
In [ ]:
fibonacci_numbers.index(13)

동시에 .copy(), .clear() 및 .sort() 같은 상위 수준 메서드는 배열에서 누락되었지만 대체 기술을 사용하여 시뮬레이션할 수 있습니다.

불변 튜플 및 문자열과 달리 Python의 배열은 해시할 수 없고 변경 가능한 시퀀스입니다 . 즉, 내용을 마음대로 수정할 수 있습니다.

In [ ]:
fibonacci_numbers.append(89)
fibonacci_numbers.extend([233, 377])
fibonacci_numbers.insert(-2, 144)
fibonacci_numbers.reverse()

fibonacci_numbers
In [ ]:
fibonacci_numbers.remove(144)

fibonacci_numbers
In [ ]:
fibonacci_numbers.pop(0)
In [ ]:
fibonacci_numbers
In [ ]:
fibonacci_numbers.pop()

이러한 메서드는 배열을 제자리에서 수정하므로 대부분의 메서드가 값을 반환 하지 않는 이유를 설명합니다. 이는 새 배열 인스턴스를 만들지 않고도 기존 배열의 내용을 변경할 수 있음을 의미합니다.

구독 구문을 사용 하면 지정된 인덱스의 개별 숫자 값을 설정하거나 삭제할 수 있을 뿐만 아니라 전체 배열 조각을 바꾸거나 제거할 수 있습니다 .

In [ ]:
fibonacci_numbers[0] = 144
del fibonacci_numbers[-1]

fibonacci_numbers
In [ ]:
fibonacci_numbers[2:9:3] = array("I", [3, 13, 55])
In [ ]:
del fibonacci_numbers[2:9:3]

fibonacci_numbers

동일한 유형 코드 로 선언된 배열 슬라이스만 할당할 수 있습니다 . 이 외에도 세 번째 단계 매개변수가 포함된 확장 슬라이스 할당을 수행할 때 주의하세요.

In [ ]:
fibonacci_numbers[2:9:3] = array("I", [3, 13, 55, 233])

모든 시퀀스와 마찬가지로 대체 슬라이스의 길이가 확장 구문을 사용할 때 대체하는 슬라이스의 길이와 동일한지 확인해야 합니다. 그 이유는 추가 요소가 연속적으로 유지되어야 하는 시퀀스에 공백을 생성하기 때문입니다. 반면, 단계가 1인 일반 조각에는 이러한 제한이 없습니다.

좋아요, 이제 배열이 Python의 다른 변경 가능한 시퀀스 유형과 인터페이스를 공유한다는 것을 알았습니다. 다음으로, 이 제품을 차별화하는 고유한 기능에 대해 알아보겠습니다.

배열과 다른 유형 간 변환

앞서 본 .itemsize 및 .typecode 속성 외에도 Python 배열은 해당 배열과 관련된 추가 메서드를 제공합니다. 특히 배열과 다른 데이터 유형간 변환을 위한 여러 가지 방법이 있습니다.

To ArrayFrom Array
.frombytes() .tobytes()
.fromfile() .tofile()
.fromlist() .tolist()
.fromunicode() .tounicode()

왼쪽 열에 나열된 메서드는 기존 값을 덮어쓰지 않고 배열에 요소를 추가합니다. 따라서 동일한 인스턴스에서 여러 번 호출하여 배열에 더 많은 숫자를 입력할 수 있습니다. 실제로 목록이나 문자열을 초기화 매개변수로 배열 생성자에 전달하면 커튼 뒤의 일부 메서드에 실행이 위임됩니다.

반대로, 오른쪽에 있는 메소드를 사용하면 해당 유형으로 배열의 내용을 표현할 수 있습니다. 이는 순수 Python에서 데이터를 처리하거나 파일로 내보내려는 경우 유용할 수 있습니다.

.fromfile() 및 .tofile() 메소드는 각각 읽기 또는 쓰기를 위해 바이너리 모드로 열린 파일 객체여야 하는 매개변수를 취하는 유일한 메소드입니다. 또한 .fromfile() 메서드는 읽을 요소 수와 함께 필수 두 번째 매개변수를 사용합니다. 따라서 파일이 저장하는 요소 수를 미리 알고 있거나 이 정보를 별도로 유지해야 합니다.

In [ ]:
from array import array
from struct import pack, unpack

def save(filename, numbers):
    with open(filename, mode="wb") as file:
        file.write(numbers.typecode.encode("ascii"))
        file.write(pack("<I", len(numbers)))
        numbers.tofile(file)

def load(filename):
    with open(filename, mode="rb") as file:
        typecode = file.read(1).decode("ascii")
        (length,) = unpack("<I", file.read(4))
        numbers = array(typecode)
        numbers.fromfile(file, length)
        return numbers

save("binary.data", array("H", [12, 42, 7, 15, 42, 38, 21]))
load("binary.data")

이 코드 조각에서 사용자 정의 save()함수는 파일의 배열 요소 수를 4바이트 부호 없는 정수( I)로 인코딩하는 데 struct.pack()을 사용합니다. struct는 배열의 유형 코드와 유사한 형식 문자를 사용합니다. 또한 이 함수는 실제.tofile()과 같이 배열 요소를 덤프하기 전에 배열의 유형 코드를 ASCII 바이트로 저장합니다.

이 load()함수는 먼저 파일의 유형 코드와 요소 수를 읽어 이러한 단계를 반대로 수행합니다. 그런 다음 올바른 숫자 유형의 빈 배열이 만들어졌다면 파일의 해당 숫자로 채우고 다음 채워진 배열을 반환하기 위해 .fromfile()를 호출합니다.

운영 체제 또는 컴퓨터 아키텍처 간에 이진 파일을 공유하는 경우 엔디안 또는 바이트 순서 의 잠재적인 차이를 고려해야 합니다. 엔디안에는 두 가지 주요 유형이 있습니다.

  1. Big-Endian:: 가장 중요한 바이트가 먼저 옵니다.
  2. Little-Endian: 최하위 바이트가 먼저 옵니다.

이러한 차이를 처리하기 위한 추가 조치를 취하지 않으면 서로 다른 바이트 순서를 사용하는 두 시스템 간에 이진 데이터를 전송할 때 문제에 직면하게 됩니다. 보다 구체적으로 말하면, 개별 바이트를 잘못된 순서로 읽으면 데이터를 잘못 해석하고 손상시킬 수 있습니다. 따라서 한 시스템에서 42와 같은 숫자를 쓰면 다른 쪽 끝에는 704,643,072로 나타날 수 있습니다.

In [ ]:
unpack("<I", pack("<I", 42))  # Consistent byte order
In [ ]:
unpack(">I", pack("<I", 42))  # Inconsistent byte order

struct 모듈은 꺾쇠 괄호 구문( 및 )을 통해 바이트 순서와 정렬을 제어할 수 있지만 Python 배열은 기본 바이트의 해석에 대해 불가지론적입니다. 그러나 조정이 필요하다는 것을 알고 있는 경우 각 배열 요소 내에서 바이트 순서를 바꿀 수 있습니다.

In [ ]:
data = b"\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02"
data_points = array("L", data)
data_points
In [ ]:
data_points.byteswap()
data_points

파일 형식이 시스템과 다른 바이트 순서를 사용한다는 것을 알고 파일의 원시 데이터를 Python 배열로 읽는 경우 바이트를 반전하여 올바른 엔디안으로 가져올 수 있습니다. 순서는 짧은 정수 또는 긴 정수와 같은 멀티바이트 숫자에만 적용되므로 단일 바이트 배열( b 및 B)는 .byteswap()에는 영향을 미치지 않습니다 .

배열의 도움으로 Python에서 이진 데이터 처리의 복잡성을 이미 깊이 파고들었습니다. 하지만 배열 요소를 처리하기 위해 사용자 정의 C 라이브러리를 사용하면 더 깊이 있게 알아볼 수 있습니다.

배열을 바이트형 객체로 처리

마지막으로, Python 배열은 버퍼 프로토콜을 지원하므로 CPython의 하위 수준 메모리 영역에 직접 액세스해야 하는 컨텍스트에서 사용할 수 있는 바이트형 객체가 됩니다 . 이를 통해 불필요한 데이터 복사를 피하면서 C 및 C++ 라이브러리 와 원활하게 통합할 수 있습니다 .

기본 버퍼에 대한 세부 정보를 얻으려면 배열의 .buffer_info()메서드를 호출하세요.

In [ ]:
from array import array
data_points = array("L", [72057594037927936, 144115188075855872])

data_points.buffer_info()
In [ ]:
id(data_points)

버퍼의 메모리 주소와 현재 보유하고 있는 총 요소 수로 구성된 튜플을 반환합니다. 버퍼의 메모리 주소는 이를 래핑하는 Python 배열 객체의 주소와 약간 다릅니다.

다음 코드를 사용하여 배열의 버퍼에 할당된 총 바이트 수를 계산할 수 있습니다.

In [ ]:
data_points.itemsize * data_points.buffer_info()[1]

data_points 배열에는 두 개의 요소가 있고 각 요소의 길이는 8바이트이므로 전체 크기는 16바이트가 됩니다. C 확장 모듈 에서 버퍼에 액세스할 때 이 정보가 필요할 수 있습니다 .

C나 다른 컴파일된 프로그래밍 언어로 Python 확장 모듈을 작성하는 것은 지루합니다. 다행스럽게도 표준 라이브러리 ctypes모듈을 사용하는 보다 간단한 방법이 있습니다. Python 스크립트에 동적으로 로드된 공유 라이브러리 에서 기본 함수를 호출할 수 있습니다. Python의 배열은 버퍼 프로토콜을 구현하므로 해당 메모리 영역에 대한 포인터를 얻을 수 있습니다.

먼저, 숫자 배열을 가져와 해당 요소를 증가시켜 해당 요소를 수정하는 기본 함수를 C로 정의합니다.

# cruncher.c

void increment(int* numbers, unsigned int length) {
for (int i = 0; i < length; i++) {
numbers[i]++;
}
}

이 함수는 부호 있는 정수 배열에 대한 포인터와 배열 길이를 기대합니다. C에서는 배열의 요소 수를 추적하지 않기 때문에 두 번째 인수가 필요합니다. 그런 다음 함수는 배열 인덱스를 반복하고 각 요소를 하나씩 증가시킵니다.

Linux 배포판을 사용하고 GNU C 컴파일러가 설치된 경우 다음 명령을 실행하여 이 코드를 공유 라이브러리로 바꿀 수 있습니다.

# terminal

gcc -shared -fPIC -O3 -o cruncher.so cruncher.c

플래그 -fPIC는 컴파일러가 동적 링크 라이브러리를 다른 프로세스에 로드하는 데 필요한 위치 독립적 코드를-O3 생성하도록 하는 동시에 최적화를 활성화합니다.

이제 Python으로 돌아가서 컴파일된 라이브러리를 로드하고 비어 있지 않은 Python 배열에 대해 사용자 정의 C 함수를 호출할 수 있습니다.

In [ ]:
from array import array
from ctypes import POINTER, c_int, c_uint, cdll

cruncher = cdll.LoadLibrary("./cruncher.so")
cruncher.increment.argtypes = (POINTER(c_int), c_uint)
cruncher.increment.restype = None

python_array = array("i", [-21, 13, -8, 5, -3, 2, -1, 1, 0, 1])
c_array = (c_int * len(python_array)).from_buffer(python_array)
cruncher.increment(c_array, len(c_array))
python_array

인수 유형과 반환 값 유형으로 구성된 기본 함수의 시그니처를 지정하는 것부터 시작합니다. 이 경우 함수는 아무것도 반환하지 않고 부호 있는 정수와 부호 없는 정수에 대한 포인터를 사용합니다. 이 서명을 정의하는 것이 중요합니다. 그렇지 않으면 Python이 C 함수를 올바르게 호출할 수 없습니다.

다음으로 C의 부호 있는 정수 유형에 해당하는 "i" 유형 코드를 사용하여 몇 개의 양수 및 음수 피보나치 수로 채워진 Python 배열을 만듭니다. 그런 다음 Python 배열을 경량 어댑터로 래핑하고 나중에 기본 함수 increment()에 전달합니다.

중요한 점은 .from_buffer() 배열 어댑터를 호출해도 새 메모리가 할당되지 않는다는 것입니다.

In [ ]:
id(memoryview(python_array))
In [ ]:
id(memoryview(c_array))

보시다시피, Python과 C 배열의 메모리 뷰는 동일한 주소를 가지며, 이는 표면 아래에서 동일한 버퍼를 가리킨다는 것을 증명합니다. 즉, C 함수는 데이터를 복사할 필요 없이 Python 배열에서 직접 작동하므로 메모리 사용량이 크게 줄어들고 성능이 향상됩니다.

C 함수가 실제로 Python 배열을 수정한다는 증거가 충분하지 않은 경우 increment()를 호출한 후 해당 요소가 어떻게 변경되는지 확인하세요. 그것들은 모두 1씩 증가하는데, 이것이 바로 컴파일된 함수가 수행해야 하는 작업입니다.

버퍼 프로토콜을 통해 Python과 C를 결합하면 특히 대규모 숫자 배열의 경우 순수 Python 코드에 비해 상당한 성능 향상을 제공하는 매우 효율적일 수 있습니다. 이는 NumPy 배열이 그만큼 빠르고 효율적이라는 비결 중 하나입니다.

이제 Python의 배열이 기존 목록보다 얼마나 뛰어난 성능을 보이는지 정확하게 확인할 차례입니다.

Python 배열의 성능 측정

공평하게 말하자면, 배열을 Python의 다른 시퀀스와 비교하는 것은 용도가 다르기 때문에 사과와 오렌지를 비교하는 것과 약간 비슷합니다. Python 배열과 가장 가까운 것은 숫자 목록입니다.

그러나 목록은 다양한 데이터 유형을 처리할 수 있는 범용 개체 컨테이너인 반면, 배열은 숫자로 제한된 고정 유형을 갖습니다. 게다가 목록은 구문에 내장되어 있어 많은 편리함을 제공합니다. 반면에 배열은 사용하기가 더 까다롭지만 메모리 오버헤드가 거의 없으며 효율적인 하위 수준 메커니즘을 활용할 수 있습니다.

지금까지 얻은 지식을 바탕으로 복잡한 수치 계산을 수행할 때 Python 배열이 목록보다 더 빠를 것으로 기대할 수 있습니다. 이러한 기대치를 테스트하려면 몇 가지 벤치마크를 실행해야 합니다. Python에서는 표준 timeit모듈을 사용하여 코드 성능을 측정하는 것이 일반적입니다 .

In [ ]:
from array import array
from timeit import timeit

large_list = list(range(10**6))
large_array = array("I", large_list)

timeit(lambda: sum(large_list), number=100)
In [ ]:
timeit(lambda: sum(large_array), number=100)

먼저, 백만 개의 Python 정수 목록과 C 부호 없는 정수의 동일한 배열을 만듭니다. 그런 다음 두 시퀀스에 대해 호출하여 timeit()해당 요소를 합산하는 데 걸리는 시간을 측정합니다. 시스템 노이즈를 고려하기 위해 지정된 코드 조각을 100번 실행합니다.

참고: 시스템 소음에는 운영 체제 또는 컴퓨터에서 실행되는 기타 응용 프로그램의 백그라운드 프로세스가 포함됩니다. 이러한 프로세스는 코드와 직접적인 관련이 없지만 성능에 영향을 미칠 수 있습니다. 벤치마크를 여러 번 실행하면 결과의 평균을 구하여 코드 조각의 실제 성능을 보다 정확하게 표현할 수 있습니다.

다소 놀랍게도 표준 Python 목록은 배열을 물 밖으로 날려버립니다. 경과된 시간(초)의 절대값은 시퀀스 길이, 컴퓨터 속도 및 기타 요인에 따라 달라지므로 중요하지 않습니다. 그러나 결론은 동일한 코드가 배열이 아닌 목록에 의존할 때 몇 배 더 빠르게 실행된다는 것입니다!

이 예상치 못한 결과에 대한 설명을 권투 오버헤드 라는 두 단어로 요약할 수 있습니다 . 즉, 내장 sum()함수는 각 배열 요소를 네이티브 C 표현에서 해당 Python 래퍼로 변환해야 하며, 이는 상당한 오버헤드를 추가합니다. Tim Peters는 Stack Overflow에서 이 현상에 대해 더 자세히 설명합니다.

저장소 는 "박스 해제"되어 있지만 요소에 액세스할 때마다 Python은 해당 요소를 사용하기 위해 해당 요소를 "박스"(일반 Python 개체에 포함)해야 합니다. 예를 들어, sum(A)배열을 반복하고 일반 Python 객체에서 각 정수를 한 번에 하나씩 상자에 넣습니다 int. 시간이 많이 걸립니다. 에서는 sum(L)목록이 생성될 때 모든 권투가 완료되었습니다.

따라서 결국 배열은 일반적으로 속도가 느리지만 훨씬 적은 메모리를 필요로 합니다. ( 원천 )

그럴 수 있지요. 순수 Python에서 배열을 처리하면 실행 속도 측면에서 다른 시퀀스 유형에 뒤처질 가능성이 높습니다 . 컴파일된 C 라이브러리의 배열에 직접 액세스하여 박싱 오버헤드를 피할 수 있는 경우에만 기회가 있습니다. Python 배열이 약속하는 메모리 절약은 어떻습니까 ?

Python 객체의 크기를 바이트 단위로 확인하려면 Pymplersys.getsizeof() 와 같은 타사 라이브러리를 호출하거나 사용하세요 . 그러면 더 정확한 결과를 얻을 수 있습니다. 배열과 숫자 목록이 소비하는 메모리 양을 측정하는 단계를 추가하여 이전 실험을 확장하세요.

In [ ]:
from sys import getsizeof
from pympler.asizeof import asizeof

getsizeof(large_array)
In [ ]:
asizeof(large_array)
In [ ]:
getsizeof(large_list)
In [ ]:
asizeof(large_list)

Python 표준 라이브러리와 Pympler는 배열 크기에 동의합니다. 32비트 정수 100만 개는 약 4MB, 즉 400만 바이트이므로 맞는 것 같습니다. 추가 80바이트는 Python 배열의 메타데이터에 해당합니다.

대조적으로, 동일한 값으로 구성된 목록은 적어도 두 배 더 큽니다. Python 목록은 실제 요소 외에 포인터 배열을 유지한다는 점을 기억하세요. 따라서 이 경우 백만 개의 정수와 또 다른 백만 개의 정수 포인터, 그리고 56바이트의 메타데이터가 필요합니다.

Pympler에 따르면 Python 목록은 동등한 배열보다 10 배나 더 많은 메모리를 사용합니다. 타사 라이브러리는 참조될 수 있는 다른 개체를 고려하여 메모리에 있는 개체 트리의 조각을 재귀적으로 탐색합니다. 따라서 sys.getsizeof()에 비해 더 큰 크기를 보고합니다.

결론적으로, Python 배열은 C의 버퍼 인터페이스를 활용하지 않을 때의 박싱 오버헤드로 인해 목록보다 느릴 수 있습니다. 그러나 배열은 데이터의 메모리 크기를 크게 줄일 수 있으며, 이로 인해 균형이 깨질 수 있습니다. 특정 사용 사례에 따라 선호됩니다. 마지막으로, Python을 저수준 코드와 더 쉽게 통합할 수 있습니다.

비표준 숫자 유형 에뮬레이션

이 array모듈을 사용하면 여러 하위 수준 C 유형 중 하나를 사용하여 숫자 배열을 선언할 수 있지만 Python은 기본적으로 세 가지 유형의 숫자만 지원합니다. 언뜻 보면 이것은 많은 것처럼 보일 수 있습니다. 그러나 일부 상황에서는 이러한 추가 숫자 유형만으로는 충분하지 않습니다. 이 섹션에서는 실제로 발견할 수 있는 비표준 숫자 유형을 언제, 어떻게 처리해야 하는지에 대한 아이디어를 제공합니다.

일반적인 숫자 유형 이해

고급 프로그래밍 언어는 숫자의 이진 표현에 대한 편리한 추상화를 제공하므로 더 큰 문제를 해결하는 데 집중할 수 있습니다. 예를 들어 Python에는 int, float및 complex가 있는 반면 JavaScri 는 이를 지배하는 Nuber 유형이 하나만 있습니다. C 및 C++와 같은 약간 낮은 수준의 언어에는 이진 데이터 작업에 필요한 데이터 저장에 대한 더 큰 제어를 제공하기 위해 더 넓은 범위의 숫자 유형이 제공됩니다.

IEEE 754 사양은 프로그래밍 언어에 관계없이 대부분의 최신 컴퓨터에서 실수의 표현과 동작을 관리합니다 . Python의 float, JavaScript의 Number 또는 C의 double를 사용하든 동일하게 작동해야 합니다. C 및 C++에서는 다른 많은 언어 중에서 단정밀도 또는 배정밀도 부동 소수점 숫자 중에서 선택할 수 있습니다. 고급 언어에서는 후자만 사용하는 경우가 많습니다.

메모리에 정수를 저장할 때 컴퓨터는 일반적으로 양수 값과 음수 값을 모두 처리할 수 있는 2의 보수 표현을 사용합니다. 일부 언어에서는 부호 있는 정수 또는 부호 없는 정수를 사용할지 결정할 수 있습니다. 부호 없는 숫자를 사용하면 표현 가능한 값 범위를 효과적으로 두 배로 늘릴 수 있습니다. Python의 정수는 항상 부호가 있지만 영리한 내부 표현을 사용하여 적은 비용으로 임의의 정밀도를 제공합니다.

Python 개발자로서 여러분은 숫자 유형과 그 표현의 복잡성에 대해 생각해 본 적이 없을 것입니다. 그러나 외부 소스에서 이진 데이터를 읽으면 해당 바이트 시퀀스를 올바르게 해석하는 방법을 알아야 합니다. 최소한 다음 질문에 대한 답을 알아야 합니다.

  • 몇 개의 연속 바이트가 숫자를 구성합니까?
  • 서명된 번호인가요, 서명되지 않은 번호인가요?
  • 어떤 바이트 순서를 따르나요?
  • 패딩이 포함되어 있나요 ?

옥텟 작업에 최적화된 현대 컴퓨터의 아키텍처 설계와 역사적인 이유로 인해 표준 숫자 유형의 비트 길이는 2의 거듭제곱입니다 . 예를 들어, 일반적인 비트 길이에는 8, 16, 32, 64비트가 포함됩니다. 프로그래밍 언어는 C에서 char, short, int및 long 같은 비트 길이에 해당하는 내장 데이터 유형을 제공합니다. Python의 모듈 array은 배열에서 사용할 수 있도록 이러한 숫자 유형을 노출합니다. 안타깝게도 작업해야 하는 이진 데이터가 이러한 표준 숫자 유형과 정확히 일치하지 않는 경우가 있습니다. 따라서 데이터를 올바르게 처리하기 위한 해결 방법을 찾아야 합니다.

모든 크기의 부호 있는 정수 해석

8비트 바이트와 2의 거듭제곱으로 정의된 배수는 디지털 정보의 표준 단위이지만 모든 데이터 유형이 이 패턴을 따르는 것은 아닙니다. 레거시 시스템이나 특수 응용 프로그램은 비표준 숫자 유형을 사용할 수 있습니다.

예를 들어, 많은 음악 스트리밍 서비스는 좋은 오디오 품질과 전송할 데이터 양 사이의 균형을 유지하기 위해 샘플당 24비트 부호 있는 정수를 기반으로 하는 형식으로 콘텐츠를 제공합니다. 비표준 오디오 비트 깊이를 사용하여 표준 16비트 CD 보다 훨씬 높은 품질을 달성하면서도 여전히 적당한 크기를 유지합니다.

불행하게도 Python 배열은 24비트 정수에 대한 유형 코드를 제공하지 않기 때문에 문제가 발생합니다. 배열의 오디오 샘플당 다음으로 더 큰 데이터 유형(32비트 정수)을 할당해야 합니다. 그런 다음 int.from_bytes()를 호출하여 수행할 수 있는 해당 바이트를 수동으로 재해석해야 합니다.

In [ ]:
audio_sample = b"\x7f\xfc>"
int.from_bytes(audio_sample)

위 예의 오디오 샘플은 3바이트 또는 24비트로 구성됩니다. int.from_bytes() 클래스 메서드를 호출한 후 원시 바이트를 스피커로 들어오는 음파의 상대적 진폭을 나타내는 Python 정수로 변환합니다.

이 기술을 Python 배열과 결합한 다음 코드 조각을 확인하세요.

In [ ]:
import wave
with wave.open("PCM_24_bit_signed.wav", mode="rb") as wave_file:
    if wave_file.getsampwidth() == 3:
        raw_bytes = wave_file.readframes(wave_file.getnframes())
        samples = array(
            "i",
            (
                int.from_bytes(
                    raw_bytes[i : i + 3],
                    byteorder="little",
                    signed=True,
                )
                for i in range(0, len(raw_bytes), 3)
            ),
        )

https://realpython.com/bonus/python-array-code/에서 파일을 다운로드 받고 압축을 풀며 audio에 PCM_24_bit_signed.wav화일이 있습니다.

Python wave 모듈을 사용하여 24비트 PCM 인코딩 WAV 파일 에서 각각 왼쪽 및 오른쪽 오디오 채널로 구성된 스테레오 프레임을 로드합니다. 다음으로, 32비트 부호 있는 정수의 새 배열을 생성하고 한 번에 세 요소씩 원시 바이트를 반복하는 생성기 표현식을 사용하여 이를 초기화합니다. 각 조각에 대해 해당 바이트를 해석하고 예상되는 바이트 순서와 부호를 명시적으로 설정합니다.

참고: 위의 구현은 전체 파일을 메모리로 읽기 때문에 최적이 아닙니다. 이는 코드를 단순하게 유지하기 위한 의도입니다. 그러나 실제로는 메모리 사용을 줄이고 효율성을 높이기 위해 스트리밍 접근 방식을 사용하여 오디오 샘플을 청크로 읽어야 합니다.

오디오 샘플을 올바르게 로드했는지 확인하려면 다음과 같은 계산을 수행할 수 있습니다.

In [ ]:
len(raw_bytes)
In [ ]:
len(samples)
In [ ]:
wave_file.getnframes()

원시 바이트는 오디오 파일의 내용을 나타냅니다. 이 경우 이는 40메가바이트, 즉 40,600,914바이트의 데이터에 해당합니다. 이 숫자를 샘플당 3바이트로 나누면 총 13,533,638개의 스테레오 샘플, 즉 채널당 6,766,819개의 샘플(프레임 수이기도 함)을 얻게 됩니다.

32비트 부호 있는 정수를 나타내는 유형 코드를 사용하여 배열을 선언했기 때문에 "i"배열이 소비하는 총 메모리 양은 파일의 원래 크기를 초과합니다.

In [ ]:
samples.itemsize
In [ ]:
samples.itemsize * len(samples)

대략 40MB 대신에 어레이는 54MB로 기록되며 이는 1/3이 더 많은 것입니다. 각 배열 요소가 3바이트 대신 4바이트를 사용하므로 이는 의미가 있습니다.

안타깝게도 C 프로그래밍 언어로 배열을 선언하더라도 비표준 숫자 유형을 사용하면 이러한 추가 비용을 피할 수 없습니다. 어쨌든 이제 사용 가능한 도구를 사용하여 이러한 비표준 숫자 유형을 에뮬레이트하는 방법을 알게 되었습니다.