반응형

이글은 책 "파이썬 클린 코드" ch2의 내용을 읽고 요약 및 추가한 내용입니다. 

 

 

pythonic 코드란?

  • 일종의 python 언어에서 사용되는 관용구

 

Pythonic 코드를 작성하는 이유

  • 일반적으로 더 나은 성능을 보임
  • 코드도 더 작고 이해하기 쉬움

 

인덱스와 슬라이스

  • 파이썬은 음수 인덱스를 사용하여 끝에서부터 접근이 가능
my_numbers = (4, 5, 3, 9)
print(my_numbers[-1]) # 9
print(my_numbers[-3]) # 5
  • slice를 이용하여 특정 구간의 요소를 얻을 수 있음
    • 끝 인덱스는 제외
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[2:5])  # (2, 3, 5)
print(my_numbers[::]) # (1, 1, 2, 3, 5, 8, 13, 21)

간격 값 조절

  • index를 2칸씩 점프
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[1:7:2])  # 1, 3, 8
  • slice 함수를 직접 호출할 수도 있음
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)

interval = slice(1, 7, 2)
print(my_numbers[interval]) # (1, 3, 8)

 

자체 시퀀스 생성

  • indexing 및 slice는 __getitem__ 이라는 매직 메서드 덕분에 동작
  • 클래스가 시퀀스임을 선언하기 위해 collections.abc모듈의 Sequence 인터페이스를 구현해야 함
class C(Sequence):                      # Direct inheritance
    def __init__(self): ...             # Extra method not required by the ABC
    def __getitem__(self, index):  ...  # Required abstract method
    def __len__(self):  ...             # Required abstract method
    def count(self, value): ...         # Optionally override a mixin method
from collections.abc import Sequence

class Items:
    def __init__(self, *values):
        self._values = list(values)

    def __len__(self):
        return len(self._values)

    def __getitem__(self, item):
        return self._values.__getitem__(item)

items = Items(1, 2, 3)
print(items[2])  # 3
print(items[0:2]) # [1, 2]
  • 다음 사항에 유의해 시퀀스를 구현해야 함
    • 범위로 인덱싱하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다. -> 지키지 않는 경우 오류 발생 가능성
    • 슬라이스에 의해 제공된 범위는 마지막 요소를 제외해야 한다. -> 파이썬 언어와 일관성 유지

컨텍스트 관리자(context manager)

  • 사전 조건과 사후 조건이 있는 일부 코드를 실행해야 하는 상황에 유용
    • 리소스 관리와 관련된 컨텍스트 관리자 자주 볼 수 있음
def process_file(fd):
    line = fd.readline()
    print(line)

fd = open("test.txt")
try:
    process_file(fd)
finally:
		print("file closed")
    fd.close()

123 file closed

똑같은 기능을 매우 우아하게 파이썬 스럽게 구현

def process_file(fd):
    line = fd.readline()
    print(line)

with open("test.txt") as fd:
    process_file(fd)

 

context manager는 2개의 매직 메소드로 구성

  • __enter__ : with 문이 호출
  • __exit__ : with 블록의 마지막 문장이 끄나면 컨텍스트가 종료되고 __exit__가 호출됨

context manager 블록 내에 예외 또는 오류가 있어도 __exit__ 메소드는 여전히 호출되므로 정리 조건을 안정하게 실행하는데 편함

예시: 데이터베이스 백업

  • 백업은 오프라인 상태에서 해야함 (데이터베이스가 실행되고 있지 않는 동안) → 서비스 중지 필요

방법 1

  • 서비스를 중지 → 백업 → 예외 및 특이사항 처리 → 서비스 다시 처리 과정을 단일 함수로 만드는 것
def stop_database():
    run("systemctl stop postgresql.service")

def start_database():
    run("systemctl start postgresql.service")

class DBHandler:
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        start_database()

    def db_backup():
        run("pg_dump database")

    def main():
        with DBHandler():
            db_backup()
  • DBHandler 를 사용한 블록 내부에서 context manager 결과를 사용하지 않음
    • __enter__에서 무언가를 반환하는 것이 좋은 습관
  • main() 에서 유지보수 작업과 상관없이 백업을 실행. 백업에 오류가 있어도 여전히 __exit__을 호출
  • __exit__의 반환 값을 잘 생각해야 함. True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전파하지 않고 멈춘다는 뜻으로 예외를 삼키는 것은 좋지 않은 습관

Context manager 구현

  1. contextlib.contextmanager 데코레이터 사용
import contextlib

@contextlib.contextmanager
def db_handler():
    try:
        stop_database()  (1)
        yield            (2)
    finally:
        start_database() (4)

with db_handler():
    db_backup()          (3)

@contextlib.contextmanager

  • 해당 함수의 코드를 context manager로 변환
  • 함수는 generator라는 특수한 함수의 형태여야 하는데 이 함수는 코드의 문장을 __enter__와 __exit__매직 메소드로 분리한다.
    • yield 키워드 이전이 __enter__ 메소드의 일부처럼 취급
    • yield 키워드 다음에 오는 모든 것들을 __exit__로직으로 볼 수 있음

 

2. contextlib.ContextDecorator 클래스 사용

import contextlib

def stop_database():
    print("stop database")

def start_database():
    print("start database")

def run(text):
    print(text)

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, ext_type, ex_value, ex_traceback):
        start_database()

@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

offline_backup()

stop database
pg_dump database
start database

  • with 문이 없고 함수를 호출하면 offline_backup 함수가 context manager 안에서 자동으로 실행됨
  • 원본 함수를 래핑하는 데코레이터 형태로 사용
    • 단점은 완전히 독립적이라 데코레이터는 함수에 대해 아무것도 모름 (사실 좋은 특성)

contextlib 의 추가적인 기능

import contextlib

with contextlib.suppress(DataConversionException):
    parse_data(nput_json_or_dict)
  • 안전하다고 확신되는 경우 해당 예외를 무시하는 기능
  • DataConversionException이라고 표현된 예외가 발생하는 경우 parse_data 함수를 실행

컴프리헨션과 할당 표현식

  • 코드를 간결하게 작성할 수 있고 가독성이 높아짐
def run_calculation(i):
    return i

numbers = []

for i in range(10):
    numbers.append(run_calculation(i))

print(numbers) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

위의 코드를 아래와 같이 바로 리스트 컴프리헨션으로 만들 수 있음

numbers = [run_calculation(i) for i in range(10)]
  • list.append를 반복적으로 호출하는 대신 단일 파이썬 명령어를 호출하므로 일반적으로 더 나은 성능을 보임

dis 패키지를 이용한 어셈블리코드 비교각 assembly 코드 (list comprehension)

import dis

def run_calculation(i):
    return i

def list_comprehension():
    numbers = [run_calculation(i) for i in range(10)]
    return numbers

# Disassemble the list comprehension function
dis.dis(list_comprehension)

def for_loop():
    numbers = []
    for i in range(10):
        numbers.append(run_calculation(i))
    return numbers

# Disassemble the for loop function
dis.dis(for_loop)

 

각 assembly 코드 (list comprehension)

  6           0 LOAD_CONST               1 (<code object <listcomp> at 0x7f8e5a78f710, file "example.py", line 6>)
              2 LOAD_CONST               2 ('list_comprehension.<locals>.<listcomp>')
              4 **MAKE_FUNCTION**            0
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_CONST               3 (10)
             10 **CALL_FUNCTION**            1
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 RETURN_VALUE

 # for loop 
 10           0 BUILD_LIST               0
              2 STORE_FAST               0 (numbers)
 11           4 SETUP_LOOP              28 (to 34)
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_CONST               1 (10)
             10 CALL_FUNCTION            1
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 STORE_FAST               1 (i)
 12          18 LOAD_FAST                0 (numbers)
             20 LOAD_ATTR                1 (append)
             22 LOAD_GLOBAL              2 (run_calculation)
             24 LOAD_FAST                1 (i)
             26 CALL_FUNCTION            1
             28 CALL_METHOD              1
             30 POP_TOP
             32 JUMP_ABSOLUTE           14
        >>   34 POP_BLOCK
 13     >>   36 LOAD_FAST                0 (numbers)
             38 RETURN_VALUE

 

리스트 컴프리헨션 예시

import re
from typing import Iterable, Set

# Define the regex pattern for matching the ARN format
ARN_REGEX = r"arn:(?P<partition>[^:]+):(?P<service>[^:]+):(?P<region>[^:]*):(?P<account_id>[^:]+):(?P<resource_id>[^:]+)"

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids

# Example usage
arns = [
    "arn:aws:iam::123456789012:user/David",
    "arn:aws:iam::987654321098:role/Admin",
    "arn:aws:iam::123456789012:group/Developers",
]

unique_account_ids = collect_account_ids_from_arns(arns)
print(unique_account_ids)
# {'123456789012', '987654321098'}

위 코드 중 collect_account_ids_from_arns 함수를 집중해서 보면,

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids

위 코드를 컴프리헨션을 이용해 간단히 작성 가능

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """

    matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
    return {m.groupdict()["account_id"] for m in matched_arns}

python 3.8이후에는 할당표현식을 이용해 한문장으로 다시 작성 가능

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """

    return {
        matched.groupdict()["account_id"]
        for arn in arns
        if (matched := re.match(ARN_REGEX, arn)) is not None
    }
  • 정규식 이용한 match 결과들 중 None이 아닌 것들만 matched 변수에 저장되고 이를 다시 사용

더 간결한 코드가 항상 더 나은 코드를 의미하는 것은 아니지만 분명 두번째나 세번째 코드가 첫번째 코드보다는 낫다는 점에서는 의심의 여지가 없음

 

프로퍼티, 속성(attribute)과 객체 메서드의 다른 타입들

파이썬에서의 밑줄

class Connector:
    def __init__(self, source):
        self.source = source
        self._timeout = 60

conn = Connector("postgresql://localhost")
print(conn.source)  # postgresql://localhost
print(conn._timeout)  # 60

print(conn.__dict__)  # {'source': 'postgresql://localhost', '_timeout': 60}
  • source와 timeout이라는 2개의 속성을 가짐
    • source는 public, timeout은 private
    • 하지만 실제로는 두 개의 속성에 모두 접근 가능
  • _timeout는 connector 자체에서만 사용되고 바깥에서는 호출하지 않을 것이므로 외부 인터페이스를 고려하지 않고 리팩토링 가능

2개의 밑줄은? (__timeout) → name mangling 으로 실제로 다른 이름을 만듦

  • _<classname>__<attribute-name>
class Connector:
    def __init__(self, source):
        self.source = source
        self.__timeout = 60

conn = Connector("postgresql://localhost")
print(conn.source)  # postgresql://localhost

print(conn.__dict__)  
# {'source': 'postgresql://localhost', '_Connector__timeout': 60}
  • __timeout → 실제 이름은_Connector__timeout 이 됨
  • 이는 여러번 확장되는 클래스의 메소드 이름을 충돌없이 오버라이드 하기 위해 만들어진거로 pythonic code의 예가 아님

결론

⇒ 속성을 private으로 정의하는 경우 하나의 밑줄 사용

 

프로퍼티(Property)

class Coordinate:
    def __init__(self, lat: float, long: float) -> None:
        self._latitude = self._longitude = None
        self.latitude = lat
        self.longitude = long

    @property
    def latitude(self) -> float:
        return self._latitude
    
    @latitude.setter
    def latitude(self, lat_value: float) -> None:
        print("here")
        if lat_value not in range(-90, 90+1):
            raise ValueError(f"유호하지 않은 위도 값: {lat_value}")
        self._latitude = lat_value

    @property
    def longitude(self) -> float:
        return self._longitude
    
    @longitude.setter
    def longitude(self, long_value: float) -> None:
        if long_value not in range(-180, 180+1):
            raise ValueError(f"유효하지 않은 경도 값: {long_value}")
        self._longitude = long_value

coord = Coordinate(10, 10)
print(coord.latitude)

coord.latitude = 190 # ValueError: 유호하지 않은 위도 값: 190
  • property 데코레이터는 무언가에 응답하기 위한 쿼리
  • setter는 무언가를 하기 위한 커맨드

둘을 분리하는 것이 명령-쿼리 분리 원칙을 따르는 좋은 방법

보다 간결한 구문으로 클래스 만들기

객체의 값을 초기화하는 일반적인 보일러플레이트

  • 보일러 플레이트: 모든 프로젝트에서 반복해서 사용하는 코드
def __init__(self, x, y, ...):
    self.x = x
    self.y = y
  • 파이썬 3.7부터는 dataclasses 모듈을 사용하여 위 코드를 훨씬 단순화할 수 있다 (PEP-557)
    • @dataclass 데코레이터를 제공
  • 클래스에 적용하면 모든 클래스의 속성에 대해서 마치 __init__ 메소드에서 정의한 것처럼 인스턴스 속성으로 처리
  • @dataclass 데코레이터가 __init__ 메소드를 자동 생성
  • field라는 객체 제공해서 해당 속성에 특별한 특징이 있음을 표시
    • 속성 중 하나가 list처럼 변경가능한 mutable 데이터 타입인 경우 __init__에서 비어 있는 리스트를 할당할 수 없고 대신에 None으로 초기화한 다음에 인스턴스마다 적절한 값으로 다시 초기화 해야함

 

from dataclasses import dataclass

@dataclass
class Foo:
    bar: list = []

# ValueError: mutable default <class 'list'> for field a is not allowed: use default_factory
  • 안되는 이유는 위의 bar 변수가 class variable이라 모든 Foo 객체들 사이에서 공유되기 때문
class C:
  x = [] # class variable

  def add(self, element):
    self.x.append(element)

c1 = C()
c2 = C()
c1.add(1)
c2.add(2)
print(c1.x)  # [1, 2]
print(c2.x)  # [1, 2]

 

아래처럼 default_factory 파라미터에 list 를 전달하여 초기값을 지정할 수 있도록 하면 됨

from dataclasses import dataclass, field

@dataclass
class Foo:
    bar = field(default_factory=list)

__init__ 메소드가 없는데 초기화 직후 유효성 검사를 하고 싶다면?

⇒ __post_init__에서 처리 가능

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 5.3의 내용을 읽고 요약한 글입니다. 

5.3.1 캐시와 메모리 상호 작용의 기본 단위: 캐시 라인

캐시 라인(Cache line)이란?

  • 프로그램이 어떤 데이터에 접근하면 다음에도 인접한 데이터에 접근할 가능성이 높으므로 데이터가 있는 곳의 ‘묶음’ 데이터를 캐시에 저장하는데 이 ‘묶음’을 캐시 라인이라고 함

캐시와 메모리가 상호 작용하는 기본 단위는 캐시 라인이며, 이 크기는 일반적으로 64 바이트임.

캐시가 적중하지 모샇는 경우 이 묶음 데이터가 캐시에 저장됨.

5.3.2 첫번째 성능 방해자: 캐시 튕김 문제

아래 2가지 코드가 있음

첫번째 코드

atomic<int> a;

void threadf()
{
	for(int i=0;i<500000000;i++)
	{
		++a;
	}
}

void run()
{
	thread t1 = thread(threadf);
	thread t2 = thread(threadf);
	
	t1.join();
	t2.join();
}
  • 2개의 스레드를 시작하는데 각 스레드를 전역변수 a값을 1씩 5억번씩 증가

두번째 코드

atomic<int> a;

void run()
{
	for(int i=0;i<1000000000;i++)
	{
		++a;
	}
}
  • 단일 스레드로 전역변수 a값을 1씩 10억번씩 증가
 

 

어떤 코드의 속도가 더 빠를까?

  • 다중 코어 컴퓨터 기준 첫번째 프로그램의 실행시간이 16초, 두번째 실행시간은 8초에 불과했음
  • 병렬 계산임에도 다중 스레드가 단일 스레드보다 느린 이유는?
  • 리눅스의 perf 도구를 사용하여 두 코드를 분석할 수 있음
  • “perf stat” 명령어는 프로그램 실행 시에 나타나는 각종 주요 정보의 통계를 보여주는데 여러 항목 중 insn per cycle 항목에서 차이를 보임

insn per cycle

  • 하나의 클럭 주기에 CPU가 실행하는 프로그램에서 기계 명령어를 몇개 실행하는지 알려줌
  • 다중 스레드는 0.15, 단일 스레드는 0.6으로 단일 스레드 프로그램에서 하나의 클럭 주기 동안 기계 명령어나 4배나 다 많이 실행되었음. 이유는?

캐시 일관성을 보장하기 위해 두 코어의 캐시에서 전역 변수 a처럼 동일한 변수가 사용될 때는 두 캐시에 모두 저장됨

 

두 스레드는 모두 해당 변수에 1을 더해야 함. 이때 첫번 째 스레드가 아래 그림과 같이 a 변수에 덧셈 연산을 실행하기 시작한다면, 다른 cpu 캐시의 a 변수를 무효화(invalidation) 해야 함 → 캐시 튕김 발생

  1. 캐시와 메모리의 불일치 문제를 방지 하기 위해 메모리의 a 변수 값도 업데이트
  2. 동시에 다른 cpu 캐시에 있는 a 변수 값을 무효화

 

1. 아래 cpu의 캐시가 무효화되어 어쩔 수 없이 메모리에서 직접 a변수 값을 읽어야 함

 

  1. 아래 cpu도 a 변수에 1을 더하고 캐시이 일관성을 보장하기 위해 메모리에 a 변수 값을 업데이트
  2. 위 cpu 캐시의 a 변수 무효화 → 또 다시 캐시 튕김이 발생

 

이와 같이 각 cpu의 캐시가 끊임없이 서로 상대 캐시를 무효화하면서 튕겨냄
⇒ 여러 스레드 사이에 데이터 공유를 피할 수 있다면 가능한 피해야 함을 의미

 

5.3.3 두번째 성능 방해자: 거짓 공유 문제

첫번째 코드

struct data
{
	int a;
	int b;
};

struct data global_data;

void add_a()
{
	for(int i=0;i<50000000;i++)
	{
		++ global_data.a;
	}
}

void add_b()
{
	for(int i=0;i<50000000;i++)
	{
		++ global_data.b;
	}
}

void run()
{
	thread t1 = thread(add_a);
	thread t2 = thread(add_b);
	
	t1.join();
	t2.join();
}
  • 스레드 2개를 시작한 후 구조체의 a 변수와 b 변수를 1씩 5억번 증가시킴

두번째 프로그램

void run()
{
	for(int i=0;i<50000000;i++)
	{
		++global_data.a;
	}
	
	for(int i=0;i<50000000;i++)
	{
		++global_data.b;
	}
}
  • 단일 스레드로 동일하게 a 변수와 b 변수를 1씩 5억 번 증가시킴
  • 첫번째 코드가 두 변수를 공유하지 않고 다중 쓰레드 프로그램이니 더 빠르게 실행될 것이라고 예상할 수 있음 → 사실을 그렇지 않음
  • 사실 두 스레드는 어떤 변수도 공유하지 않지만 이 두 변수는 동일한 캐시 라인(cache line)에 있을 가능성이 매우 높아 캐시 튕김 문제가 발생할 수 있음 ⇒ 거짓 공유(false sharing)이라고 함

개선하는 방법으로 두 변수가 같은 캐시라인에 있지 않도록 하는 것인데 아래처럼 구조체를 구성하면 가능

struct data
{
	int a;
	int arr[16];
	int b;
}
  • 다중 코어 컴퓨터에서 캐시 라인 크기가 64바이트이며, arr[16]을 통해 int 형식의 배열을 채우면 a 변수와 b변수는 다른 캐시라인에 있게 됨.
반응형
반응형

Python 공식문서에 따르면 super 클래스의 역할은 아래와 같음

Return a proxy object that delegates method calls to a parent or sibling class of type. This is useful for accessing inherited methods that have been overridden in a class.

공식문서 설명은 늘 어려움.

쉽게 말해, 부모나 형제 클래스의 임시 객체를 반환하고, 반환된 객체를 이용해 슈퍼 클래스의 메소드를 사용할 수 있음.

즉, super() 를 통해 super class의 메소드에 접근 가능

단일상속에서 super()

 
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

 

class Square(Rectangle):
    def __init__(self, length):
        super().__init__(length, length)

square = Square(4)
square.area() # 16
  • Rectangle 클래스를 상속받기 때문에 Rectangle의 area() 메소드 사용 가능

 

super() with parameters


  • super() 는 2가지 파라미터를 가질 수 있음
    • 첫번째 : subclass
    • 두번째 : subclass의 인스턴스 객체
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

class Square(Rectangle):
    def __init__(self, length):
        super(Square, self).__init__(length, length)
  • 단일 상속인 경우에는 super(Square, self)와 super()는 같은 의미

아래의 경우는?

class Cube(Square):
    def surface_area(self):
        face_area = super(Square, self).area()
        return face_area * 6

super(Square, self).area()

첫번째 argument : subclass 인 Square

  • Cube가 아닌 Square기 때문에 super(Square, self)의 반환은 Square 클래스의 부모 클래스인 Rectangle 클래스의 임시 객체
  • 결과적으로 Rectangle 인스턴스에서 area() 메소드를 찾음

Q. Square 클래스에 area 메소드를 구현하면??

  • 그래도 super(Square, self) 가 Rectangle 클래스를 반환하기 때문에 Rectangle 인스턴스에서 area() 메소드를 호출
## super 클래스의 정의
class super(object):
	def __init__(self, type1=None, type2=None): # known special case of super.__init__
	        """
	        super() -> same as super(__class__, <first argument>)
	        super(type) -> unbound super object
	        **super(type, obj) -> bound super object; requires isinstance(obj, type)
	        super(type, type2) -> bound super object; requires issubclass(type2, type)**
	        Typical use to call a cooperative superclass method:
	        class C(B):
	            def meth(self, arg):
	                super().meth(arg)
	        This works for class methods too:
	        class C(B):
	            @classmethod
	            def cmeth(cls, arg):
	                super().cmeth(arg)
					"""
	        
	        # (copied from class doc)

두번째 argument : 첫번째 argument의 클래스 인스턴스를 넣어주거나 subclass를 넣어줘야함

print(issubclass(Cube, Square)) # True
반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 5.2의 내용을 읽고 요약한 글입니다. 

5.2.1 프로그램 지역성의 원칙

프로그램 지역성의 원칙(locality of reference or principle of locality)

  • 본질은 매우 규칙적으로 메모리에 접근한다는 것으로 크게 2가지 종류가 있음
 

 

(1) 시간적 지역성 (temporal locality)

  • 프로그램이 메모리 조각에 접근하고 나서 이 조각을 여러번 참조하는 경우를 이야기 함
  • 캐시 친화성이 매우 높은데, 데이터가 캐시에 있는한 메모리에 접근하지 않아도 반복적으로 캐시의 적중이 가능하다는 단순한 이유

 

(2) 공간적 지역성(spatial locality)

  • 캐시가 적중하지 않으면 메모리의 데이터를 캐시에 적재해야 함
  • 일반적으로 요청한 메모리의 인접 데이터도 함께 캐시에 저장되므로 프로그램이 인접 데이터에 접근할 때 캐시가 적중하게 됨

 

5.2.2 메모리 풀 사용

  • 메모리를 동적으로 할당받을 때 일반적으로 malloc을 이용
  • 메모리 조각 N개를 할당받을 때 malloc을 사용하면 N개 조각이 힙 영역의 이곳 저곳에 흩어져 공간적 지역성에 좋지 않음
  • 메모리 풀 기술은 커다란 메모리 조각(연속적인 메모리 공간)을 미리 할당받으며 메모리 요청/해제 할 때 더 이상 malloc을 거치지 않아 캐시 친화적

 

5.2.3 struct 구조체 재배치

Linked list에 특정 조건을 만족하는 노드가 있는지 판단하려고 할 때, 구조체는 다음과 같이 정의

#define SIZE 100000

struct List
{
	List* next;
	int arr[SIZE];
	int value;
}

Linked list의 노드에는 필요한 값 value와 다음 노드를 가리키는 next 포인터 외에도 배열 arr이 포함되어있음

bool find(struct List* list, int target)
{
	while(list)
	{
		if(list->value == target)
			return true;
		
		list = list->next;
	}
	
	return false;
}
  • 위 코드에서 빈번하게 사용되는 항목은 next 포인터와 value값이며, 배열 arr은 전혀 사용되지 않음
  • 하지만 next 포인터와 value 값이 배열 arr에 의해 멀리 떨어져 있기 때문에 공간적 지역성이 나빠질 수 있어 아래 처럼 함께 배치 → 캐시 적중률 상승시킬 수 있음. 
#define SIZE 100000

struct List
{
	List* next;
	int value; // next 아래에 value
	int arr[SIZE];
}

 

5.2.4 핫 데이터와 콜드 데이터의 분리

  • 핫데이터 (hot data): 더 빈번하게 사용되는 데이터
  • 콜드 데이터(cold data): 덜 빈번하게 사용되는 데이터

위 코드에서 next, value는 핫데이터, arr는 콜드 데이터라고 할 수 있음

일반적으로 Linked list에 노드가 하나뿐인 경우는 거의 없으며, 노드가 비교적 많을 때에는 캐시해야 할 노드가 비교적 많아짐 → 캐시 용량은 제한적!

Linked list 자체가 차지하는 저장 공간이 클수록 캐시에 저장할 수 있는 노드는 줄어듬. 아래와 같이 배열 arr을 다른 구조체에 넣고 List 구조체 안에 이 구조체를 가리키는 포인터를 추가할 수 있음

#define SIZE 100000

struct List
{
	List* next;
	int value; // next 아래에 value
	strcut Arr* arr;
};

struct Arr
{
	int arr[SIZE];
};
  • 콜드 데이터와 핫 데이터를 분리하면 더 나은 지역성을 얻을 수 있는데 이런 방법을 사용하려면 각 항목하다 접근 빈도를 알고 있어야 함

 

5.2.5 캐시 친화적인 데이터 구조

  • 지역성 원칙 관점에서 배열이 linked list보다 나음
    • C++, std::vector 컨테이너가 std::list 컨테이너 사용보다 나음
  • 실제로 사용할 때는 캐시 친화적 여부를 포함하여 구체적인 상황에 맞추어 선택해야 함
  • 예를 들어, 배열의 공간적 지역성은 linked list보다 낫지만, 노드의 추가/삭제가 빈번하게 발생하는 경우에는 linked list가 배열에 비해 우수함
    • Linked list의 노드 추가/삭제에 대한 시간 복잡도 O(1)
  • 노드의 추가/삭제가 쉬운 장점을 유지하면서 캐시 친화적이고 싶은 linked list를 사용하려면? → 직접 정의한 메모리 풀에서 메모리를 요청하면 됨

이런 최적화를 진행할 때 반드시 분석 도구를 사용하여 캐시의 적중률이 시스템 성능에 병목이 되는지 판단해야 함. 병목이 되지 않으면 굳이 이런 최적화를 할 필요가 없음

 

5.2.6 다차원 배열 순회

  • 배열을 row, column 순서로 순회하면서 값을 모두 더하는데 C언어 역시 row 우선 방식(row major)으로 배열을 저장
    • row major: 메모리에 저장할 때 첫번째 행이 첫번째 열부터 마지막 열까지 저장되고, 이어서 두번째 행이 첫번째 열부터 마지막 열까지 저장되는 것을 반복하는 구조
int matrix_summer(int A[M][N])
{
    int i, j, sum = 0;
	
    for(i=0; i< M;i++)
    {
        for(j=0;j<N;j++)
        {
            sum += A[i][j];
        }
    }
	
    return sum;
}

 

  • 배열에 행 4개와 열 8개가 있다고 가정 (M이 4, N이 8)
  • 캐시가 최대 4개의 int 형식 데이터를 저장할 수 있다고 가정

  • 순회가 시작되면 캐시에 아직 배열에 대한 데이터가 없기 때문에 캐시는 비어있어 배열 A의 첫번째 요소인 A0에 접근하는 시점에 캐시가 적중할 수 없음 → A0를 포함한 요소 4개가 캐시에 저장됨

 

  • 캐시 준비가 완료되어 A1부터 A3에 접근할 때는 모두 캐시가 적중하기 때문에 메모리에 접근할 필요가 없음

 

  • A4에 접근할 때는 캐시 용량의 제한으로 캐시가 적중할 수 없는데 다시 A4개를 포함한 요소 4개가 캐시에 저장되어 이전 데이터와 교체됨

  • 각 행마다 8개 요소들 중 2개를 miss하기 때문에 75% 의 캐시 적중률을 보여줌

 

이번에는 열 우선 방식으로 접근 (column major)

int matrix_summer(int A[M][N])
{
	int i, j, sum = 0;
	
	for(j=0; j< N;j++)
	{
		for(i=0;i<M;i++)
		{
			sum += A[i][j];
		}
	}
	
	return sum;
}

 

첫번째 요소인 A0에 접근하면 아직 캐시가 적중할 수 없기 때문에 아래와 같이 A0부터 A3까지 캐시에 저장됨

그러나 우리 코드에서 다음에 접근할 요소는 A8이며 이 데이터는 여전히 캐시가 적중할 수 없고 캐시에 A8부터 A11을 저장. 이전 캐시는 사용되지 못함

  • 위와 같이 열 우선 방식으로 배열을 순회하면 캐시는 매번 적중에 실패하여 적중률은 0

 

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 5.1의 내용을 읽고 요약한 글입니다. 

5.1.1 CPU와 메모리의 속도 차이

https://softwareg.com.au/blogs/computer-hardware/cpu-bandwidth-vs-memory-bandwidth

  • CPU 속도는 1년에 52%씩 성능이 증가한 반면, 메모리 속도는 1년에 7%씩 증가
  • CPU는 명령어를 실행할 때 어쩔 수 없이 메모리의 처리를 기다려야 함
    • 일반적인 시스템에서 메모리의 속도는 CPU의 100분의 1

 

5.1.2 도서관, 책상, 캐시

  • 도서관에서 책상은 캐시에 비유할 수 있고, 서가는 메모리에 비유 가능
  • CPU와 메모리 사이에 캐시 계층이 추가되어, 최근에 메모리에서 얻는 데이터가 저장됨
  • 캐시가 적중하면 메모리에 접근할 필요가 없어 CPU가 명령어 실행하는 속도를 크게 끌어올릴 수 있음
    • 캐시 적중: CPU가 요청한 데이터가 캐시에 이미 저장되어있어 캐시가 해당 요청을 이행할 수 있을 때 발생

CPU는 메모리와 직접 상호 작용하지 않고 캐시와 상호 작용

 

 

  • 일반적으로 x86 CPU와 메모리 사이에는 세 단계의 캐시가 추가 되어 있음
    • L1, L2, L3
  • L1 캐시 접근 속도: 레지스터 접근 속도에 비해 약간 느리지만 대동 소이하기 때문에 대략 4클럭 주기가 소요됨
  • L2캐시 접근 속도: 대략 10클럭 주기 소요
  • L3캐시 접근 속도 : 대략 50클럭 주기 소요
  • 캐시 단계에 따라 접근 속도는 낮아지지만 용량을 증가

  • L1, L2, L3 캐시, CPU 코어는 레지스터 칩 내에 묶여 패키징되어 있음
  • CPU가 메모리 내에 데이터가 필요하다고 판단하면 우선 L1 캐시에서 데이터를 검색
  • 없다면 L2, L3 를 순차적으로 검색 후 모두 없으면 메모리에 직접 접근하여 캐시에 데이터를 갱신

 

5.1.3 공짜 점심은 없다: 캐시 갱신

불일치 문제

  • 캐시의 데이터는 갱신되었지만 메모리의 데이터는 아직 예전 것이 남아 있어 캐시와 메모리 사이에 불일치가 발생할 수 있음

해결 방법

1. 연속 기입(write-through)

  • 캐시를 갱신할 때 메모리도 함께 갱신하는 방법
  • 캐시를 업데이트하면 어쩔 수 없이 메모리에 접근해야 함 → CPU는 메모리가 갱신될 때까지 대기하고 있어야 함 → 동기식 설계 방법
  • 이 상황을 최적화하는 방법은 동기를 비동기로 바꾸는 것

2. 후기입(write-back)

  • 캐시 용량에 한계가 있어 용량이 부족하면 자주 사용되지 않는 데이터를 제거해야 함
  • 캐시에서 제거된 데이터가 수정된 적이 있다면 이를 메모리에 갱신해야 함
  • 캐시의 갱신과 메모리의 갱신이 분리되므로 비동기임
  • 연속 기입보다 훨씬 복잡하지만 성능은 분명 더 나음

자주 사용할만한 데이터를 캐싱하는데, 삭제할 때만 메모리에 갱신하는 방식이 후기입

 

5.1.4 세상에 공짜 저녁은 없다: 다중 코어 캐시의 일관성

  • 단일 CPU 성능 향상이 어려워 숫자를 늘리게 됨 → 다중 코어 시대 진입
  • CPU가 코어를 여러개 가지면 다른 문제가 발생하는데 두 코어에서는 다른 스레드 2개가 실행됨

Step 1

  • 두 스레드 모두 메모리 내 X변수에 접근해야하고 변수의 초기값이 2라고 가정
  • 코어 2개는 모두 X 변수를 사용해야 한다

캐시 동작 원리에 따르면,

  • 처음으로 X 변수를 읽으면 캐시에 적중할 수 없으므로 X 변수를 메모리에서 읽어야 함
  • 이어서 대응하는 캐시에 갱신되므로, 이제 cpu1 캐시와 cpu2 캐시는 모두 X 변수를 가지고 있고 그 값은 2

 

Step2

  1. C1은 X 변수에 2를 더해야 하는데 캐시 동작 원리에 따라 C1은 캐시에서 X 변수 값을 가져온 후 2를 더해서 다시 캐시에 갱신
  2. 이 시스템이 연속 기입(write-through) 방식이라고 가정하면 메모리도 동기 방식으로 동시에 갱신되고 메모리에 있는 X 변수 값은 4

 

Step3

  1. cpu2 도 X 변수에 덧세 연산을 수행하는데 4를 더한다고 가정
    1. 캐시 동작 원리에 따라 cpu2는 캐시에서 X 변수 값을 가져온 후 4를 더해서 다시 캐시를 갱신
    2. 캐시 값은 6으로 바뀌는데 연속 기입 방식에 따라 메모리를 갱신하면 메모리에 저장된 값도 6이 됨 ⇒ 문제 바생
  • 초기 값이 2인 변수에 2 와 4를 각각 더한 후 결과는 8이 되어야 하지만 X 변수 값은 6이 됨

문제는 메모리의 X 변수에 cpu1, cpu2의 캐시에 복사본 2개를 가지고 있기 때무네 발생하고 C1 캐시가 갱신될 때 C2 캐시는 동기적으로 수정되지 않아 다중 코어에서 캐시 데이터의 불일치 문제가 발생함

⇒ 해결책: 캐시 한개에 갱신된 변수가 다른 CPU 코어의 캐시에도 존재한다면 함께 갱신되어야 함

  • 최신 CPU에는 고전적인 MESI 규칙(MESI protocol)같은 다중 코어 캐시의 일관성을 유지하는 규칙이 있음

 

5.1.5 메모리를 디스크의 캐시로 활용하기

  • 디스크는 탐색(seek) 을 위해 10ms 가량의 시간이 소요됨 (메모리 접근 속도가 10만배 가량 빠름)
    • 물론 모든 디스크 접근에 탐색이 필요하지는 않음

메모리와 디스크의 속도 차이를 해결하기 위해 둘 사이에 직접 캐시를 추가하면 어떨까?

먼저 레지스터가 메모리의 캐시가 될 수 없는 이유는 레지스터 크기가 한정되어있기 때문. 메모리 용량은 사실 꽤 큰편이라(GB 단위) 어려움

  • 최신 운영체제는 분명히 메모리를 디스크의 캐시로 사용
  • 일반적으로 메모리 사용률은 100%에 도달하지 않으며 이 여유 공간을 낭비시키지 않도록 디스크의 캐시로 활용하여 디스크에서 데이터 읽어 오는 일을 최소화 함 → 페이지 캐시의 기본원리

결국 CPU의 내부 케시가 메모리 데이터를 저장하고, 메모리가 디스크 데이터를 저장

  • 최근 서버에서는 메모리가 디스크를 대체하는 것이 대세 → 메모리 가격이 엄청 저렴해졌기 때문
  • AWS에서는 2TB 용량의 메모리를 탑재한 인스턴스도 제공
  • Presto, Flink, Spark 같은 메모리 기반 시스템이 디스크 기반의 경쟁자들을 빠르게 대체하고 있음

 

5.1.6 가상 메모리와 디스크

  • 앞서 모든 프로세스가 표준 크기의 자체적인 주소 공간을 가지고 있다고 여러번 이야기 함
    • 물리 메모리와는 관련이 없어 물리 메모리의 크기를 초과활 수 있음

Q. 시스템에 프로세스 N개가 실제 물리 메모리를 모두 사용하고 있을 때 새로운 프로세스가 생성되어 N+1번째 프로세스도 메모리를 요청한다면 시스템이 어떻게 처리할까?

A. 디스크는 메모리의 창고 역할을 할 수 있어 자주 사용하지 않은 메모리 데이터를 디스크에 기록하고 데이터가 차지하던 물리 메모리 공간을 해제할 수 있음. 그러면 N+1 번째 프로세스가 다시 메모리를 요청할 수 있음

 

5.1.7 CPU는 어떻게 메모리를 읽을까?

  • CPU가 볼 수 있는 것은 모두 가상 메모리 주소로 실제 물리 메모리 주소로 변환되어야 함
  • 변환이 완료되면 캐시를 검색하기 시작하고 캐시에서 찾을 수 없을 때는 메모리에 접근
  • 프로세스의 데이터는 디스크에 임시로 보관되어 있을 수 있는데 이때 메모리에서 데이터를 찾을 수 없을 가능성이 있으며 디스크의 프로세스 데이터를 다시 메모리에 적재한 후 메모리를 읽어야 함
  • 빅 데이터의 시대가 도래해 단일 장치의 디스크만으로 더이상 데이터를 완전히 저장할 수 없다면 대안은?

 

5.1.8 분산 저장 지원

  • 분산 파일 시스템(distributed file system)을 직접 장착(mount)할 수도 있고, 로컬 디스크는 원격의 분산 파일 시스템에서 전송된 파일을 저장 → 로컬 디스크를 원격의 분산 파일 시스템의 캐시로 간주할 수 있음
  • 물론 응답 속도를 높이기 위해 원격 분산 파일 시스템의 데이터를 데이터 흐름(data stream) 형태로 직접 로컬 컴퓨터 시스템의 메모리로 끌어올 수도 있음 (apache kafka)
  • 이 경우 대용량 메시지는 원격 분산 파일 시스템에 저장되어 있지만 실시간으로 해당 데이터는 소비자에게 전달되고 이때 메모리를 원격 분산 파일 시스템의 캐시로 간주
반응형

+ Recent posts