Python 네임스페이스에 대한 이해

어쩌다보니 네임스페이스란 단어를 들었다. 파이썬을 다룬지 좀 되었음에도 불구하고 네임스페이스란 단어는 참 오랜만에 듣는다는 생각이 들었다. 자바나 C#할 때나 신경썼지... 파이썬할 때는 제대로 생각해 본 적이 없다.

그래서 파이썬의 네임스페이스는 어떻게 되어있을까 자료를 찾다가 예전에 읽었던 책이 떠올라 먼지로 덮인 책장을 파헤쳐 "High Performance Python"라는 책을 다시 읽었다.

그래서 이 책을 참고하여 관련 내용을 적어보려고 한다.

파이썬은 개체를 어디서 어떻게 찾는가?

개체를 어디서 어떻게 찾는가가 바로 네임스페이스에 관한 내용이다. 파이썬에는 지역 변수 딕셔너리 locals와 전역 변수 딕셔너리 globals가 있다.

print(locals())
print(globals())

'''
Outputs:
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7ff0f38676a0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'test.py', '__cached__': None}
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7ff0f38676a0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'test.py', '__cached__': None}
'''

global context에서 두 딕셔너리를 호출하면 차이는 거의 없다.

다음과 같이 global context와 local context가 차이날 때 그 결과가 달라진다.

def f():
    a = 1
    print(locals())
    print(globals())

f()

'''
Outputs:
{'a': 1}
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7fed02a0b6a0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': 'test.py', '__cached__': None, 'f': <function f at 0x7fed02a4e310>}
'''

어떤 개체를 호출할 때 파이썬 인터프리터는 먼저 locals()에서 개체를 찾고, 없으면 그 다음 globals()에서 찾는다. 딕셔너리 내부에서 탐색하는 것이며, 이는 대부분의 경우 O(1)O(1)의 시간 복잡도를 가진다. (딕셔너리 탐색에 대해서는 hash function에 대해서도 논해야 하므로 할 얘기가 많으니 넘어가겠다...)

globals에도 없을 때에는 __builtins__에서 찾는다. 앞선 두 딕셔너리와 다르게 __builtins__는 모듈이다.

print(__builtins__)
print(dir(__builtins__))

'''
Outputs:
<module 'builtins' (built-in)>
['ArithmeticError', ..., 'ZeroDivisionError', '__build_class__', ..., '__spec__', 'abs', ..., 'zip']
'''

모듈로부터 개체를 가져올 때에는 해당 모듈의 context에서 locals를 호출, 그 locals 딕셔너리에서 원하는 개체를 탐색한다.

속도가 중요한 코드에서 이러한 부분을 잘 신경써야 하는데, 파이썬에서는 로직을 최적화하여 main context의 locals는 딕셔너리 탐색을 하지 않고 바로 개체를 찾을 수 있다고 한다. 그러나 모듈의 locals에서 개체를 가져올 때는 딕셔너리 탐색을 진행하므로 속도 차이가 크다.

디스어셈블링을 통한 네임스페이스별 호출 속도 차이 측정

요즘따라 즐겨 쓰는 datetime 모듈로 실험을 해보겠다.

import datetime
from datetime import timedelta

먼저 시간 측정을 위해 calc_time 함수를 정의한다.

def calc_time(func):
    st = time()
    func()
    et = time()
    return et - st

그리고 서로 다른 방식으로 timedelta 클래스를 가져오는 함수를 작성한다.

def test1():
    for _ in range(10**6):
        datetime.timedelta(1, seconds=1)

def test2():
    for _ in range(10**6):
        timedelta(1, seconds=1)

def test3(timedelta=datetime.timedelta):
    for _ in range(10**6):
        timedelta(1, seconds=1)

test1은 globals에서 datetime을 찾고, 해당 모듈의 locals에서 timedelta를 찾는다.
test2은 globals에서 timedelta를 바로 찾아 가져온다.
test3은 locals에서 timedelta를 바로 찾아 가져온다.

times = [
    calc_time(test1),
    calc_time(test2),
    calc_time(test3),
]

print(times)
'''
Outputs:
[0.29599452018737793, 0.2738306522369385, 0.2707936763763428]
'''

시간 차이가 나름 유의미하게 발생한다는 것을 알 수 있다.

이를 좀 더 디테일하게 파고들어보자.

import dis

dis.dis(test1)
print('-' * 100)
dis.dis(test2)
print('-' * 100)
dis.dis(test3)

dis 모듈은 특정 함수를 디스어셈블링하여 바이트 코드를 보여주는 모듈이다.

 13           0 LOAD_GLOBAL              0 (range)
              2 LOAD_CONST               1 (1000000)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                18 (to 28)
             10 STORE_FAST               0 (_)

 14          12 LOAD_GLOBAL              1 (datetime)
             14 LOAD_ATTR                2 (timedelta)
             16 LOAD_CONST               2 (1)
             18 LOAD_CONST               2 (1)
             20 LOAD_CONST               3 (('seconds',))
             22 CALL_FUNCTION_KW         2
             24 POP_TOP
             26 JUMP_ABSOLUTE            8
        >>   28 LOAD_CONST               0 (None)
             30 RETURN_VALUE
----------------------------------------------------------------------------------------------------
 17           0 LOAD_GLOBAL              0 (range)
              2 LOAD_CONST               1 (1000000)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                16 (to 26)
             10 STORE_FAST               0 (_)

 18          12 LOAD_GLOBAL              1 (timedelta)
             14 LOAD_CONST               2 (1)
             16 LOAD_CONST               2 (1)
             18 LOAD_CONST               3 (('seconds',))
             20 CALL_FUNCTION_KW         2
             22 POP_TOP
             24 JUMP_ABSOLUTE            8
        >>   26 LOAD_CONST               0 (None)
             28 RETURN_VALUE
----------------------------------------------------------------------------------------------------
 21           0 LOAD_GLOBAL              0 (range)
              2 LOAD_CONST               1 (1000000)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                16 (to 26)
             10 STORE_FAST               1 (_)

 22          12 LOAD_FAST                0 (timedelta)
             14 LOAD_CONST               2 (1)
             16 LOAD_CONST               2 (1)
             18 LOAD_CONST               3 (('seconds',))
             20 CALL_FUNCTION_KW         2
             22 POP_TOP
             24 JUMP_ABSOLUTE            8
        >>   26 LOAD_CONST               0 (None)
             28 RETURN_VALUE

test1은 LOAD_GLOBAL과 LOAD_ATTR로 timedelta를 가져온다.
test2는 LOAD_GLOBAL로 timedelta를 가져온다.
test3는 LOAD_FAST로 timedelta를 가져온다.

실제 바이트 코드에서도 연산량의 차이를 보인다.

나는 이게 정량적으로 얼마나 차이가 나는가 알아보고 싶었는데, 정확하게 실험하는 게 너무 어려워서 포기했다. 가령, LOAD_CONST 명령 하나 만큼의 차이를 알아보기 위해 그런 함수를 작성했더니 CALL_FUNCTION_KW에서 넘겨주는 인자 개수 차이가 나는 등 교란 원인이 너무 많았다.

혹여 방법을 아는 이가 있다면 알려주시길 바란다...ㅠ

test1의 시간에서 test2의 시간을 빼면 바로 LOAD_ATTR에 의해 소요된 시간을 알 수 있는데, 대충 0.02초 정도 된다. 10610^6번만 반복했음에도 이 정도의 시간차이가 난다는 것은, LOAD_ATTR 명령어가 심히 느리다는 걸 말한다.

이것이 바로 (처음에 말했듯) 해당 모듈의 context dictionary로부터 딕셔너리 탐색 알고리즘을 통해 개체를 가져오면서 걸리는 시간 차이다.

대충 실험해보면 LOAD_GLOBAL과 LOAD_FAST는 2배 정도 차이가 나는데, 애초에 그냥 그 시간 자체가 너무 적어서 (100만번 반복해도 해당 명령어로부터 0.001 단위의 시간이 걸린다) 무시할 만했다.

아무튼 이러이러해서 파이썬의 네임스페이스 구조와 이로부터 발생할 수 있는 시간차이를 알아보았다. 파이썬은 정말 심각하게 느린 언어이기에 주로 속도를 신경쓰지 않는 사람들이 쓴다고 생각하겠지만, 사실 science 관련 분야로 가면 그 누구보다 무거운 연산을 하는 사람들이 많다.

당장 나도 데이터 ETL 과정에서 시간이 정말 오래 걸리기에, 이런 부분을 알아두면 체감될 정도의 속도 개선을 이룰 수 있을 거라 생각한다.
물론 대부분의 경우 이런 것보다 다른 부분에서 더 큰 개선을 이룰 수 있을 것이다.

좋은 웹페이지 즐겨찾기