캐시

조정훈·2024년 8월 13일

cpu와 메모리의 속도차이가 엄청 크기 때문에 cpu가 메모리를 기다려야 하는 상황
-> 성능저하

캐시를 활용하면 이 부분 해결 가능

L1(속도 레지스터랑 거의 비슷한수준) -> L2 -> L3
으로 가면 갈수록 속도는느려지지만 용량이 많아진다
실제로 cpu가 메모리에 접근해야할 때 이 순서로 접근한다(miss시 다음으로 넘어가서 찾음)
마지막 L3까지 갔는데도 없으면 그 때는 메모리에 직접 접근한다.

L1,L2,L3,cpu코어는 레지스터 칩 내에 묶여 패키징 되어있다.

cpu는 캐시를 통해 메모리 데이터 통신하는데
불일치 문제 생길 수 있음
-> 캐시랑 메모리의 값이 다른 현상

해결법)
캐시 갱신할 때 메모리도 같이 갱신하기
-> 이 방식을 하려면 메모리에 접근해야 되는데 이러면 cpu가 메모리 갱신될 떄 까지 기다려야해서 느림(연속기입방식이라고 하며 동기식)

비동기식으로 할 수 있다.(메모리 갱신 완료 안기다리고 cpu가 다음 작업을 진행함. 후기입방식)


문제상황) cpu1이 어떤 연산을 통해 한 변수의 값을 변경시켜서 캐시,메모리에 갱신한 상황에서 이 다음 cpu2가 이 변수로 연산을 진행하려고 하면 이전의 캐시에 있는 값(cpu1의 연산에의해 업데이트 되지 않은) 으로 연산을 하기 때문에 원하는 값이 안나올 수 있다.

해결법) 다른 코어의 연산에서 캐시에 갱신될 때 다른코어에 같은 변수가 있다면, 그 때 같이 갱신해주면 됨.


파일 실행 할 때 디스크작업도 생각해야함. 디스크는 탐색작업에서 메모리보다 10만배 느리다
이러면 메모리 <-> 디스크 속도차이 발생하는 문제생김(cpu와 메모리때 처럼)

해결법) 메모리를 디스크의 캐시로 활용하면된다. 실제로 최신 운영체제는 이렇게 활용한다. (기존에 레지스터를 메모리의 캐시로 활용하지 못한 이유는 레지스터 용량이 작아서임. 메모리는 용량 커서 가능)
일반적으로 메모리 사용률100% 아니니까 남은 공간으로 디스크의 캐시로 활용하는것임

여기서도 캐시 갱신 문제 발생할건데 어떻게 해결?
->

  1. Write-Through : 캐시데이터 수정할 떄 마다 즉시 디스크에 반영. 데이터 일관성 유지되지만 매번 디스크 쓰기 해야해서 디스크i/o 많이 생김 -> 속도 느림

  2. Write-Back : 데이터 수정시 캐시에만 데이터 수정하고 디스크에는 나중에 쓰기(dirty bit활용). 캐시에서 수정된 데이터를 dirty로 표시.(dirty = cpu에 의해 수정되었지만 아직 메모리나 디스크에는 반영되지 않은 상태. 반영시 clean으로 변경)
    제거or특정조건 만족시 디스크에 기록
    캐시에서만 수정하므로 쓰기 성능 향상, 디스크i/o 적음. but 데이터 일관성 문제 생길수있음

  3. Write-Around : 캐시에 데이터 없으면 디스크에 직접 쓰기 수행하고 캐시에는 갱신하지 않기(읽기작업만 캐시로함). 읽기성능 향상, 쓰기 후에 갱신하지 않으므로 이후에 캐시미스 가능성

  4. 캐시일관성 프로토콜 (MESI, MOESI등)으로 캐시간 데이터 불일치 방지
    멀티캐시 환경에서 데이터 일관성 보장, but 복잡하다. 구현어렵다.

  5. 동기or비동기 쓰기 선택. 동기는 일관성 높지만 느림, 비동기는 빠르지만 시스템 중단시 일관성 손상 위험있음

  6. 파일 시스템 저널링. 파일 시스템에서 데이터 갱신 시 실제 데이터 갱신하기 전에 저널(로그)에 기록하는 방법. 시스템 충돌 시 이 로그를 통해 데이터 복구 가능

  7. Dirty Bit :
    캐시의 각 블록에 더럽혀진 여부를 표시하는 비트를 둬서 데이터가 디스크와 불일치하는지 표시해둠. (dirty = cpu에 의해 수정되었지만 아직 메모리나 디스크에는 반영되지 않은 상태. 반영시 clean으로 변경)

  8. LRU(Least Recently Used) 알고리즘.
    : 가장 오래 사용되지 않은 데이터를 교체하는 알고리즘. 캐시 효율성을 높임 dirty bit를 함께 사용해서 필요한 경우에만 디스크에 데이터 쓰도록 할 수 있음


각각 장단점이 있으니 필요한 전략을 사용하면 된다


가상메모리

가상메모리는 물리메모리랑은 상관없이 크기를 초과할 수 있다.
어떻게?
-> 디스크를 메모리의 창고역할로 사용해서 자주사용하지 않는 메모리데이터를 디스크에 넣어놓고 물리메모리공간 해제해서 다음에 다시 요청시 디스크에서 꺼내씀. 이런식으로 프로세스가 요청하는 메모리크기가 물리메모리를 넘어설 수 있다.(운영체제가 알아서 함)

cpu가 보는것은 가상메모리주소.
이게 실제 물리메모리주소로 변환되면 L1, L2, L3 캐시에서 검색. 없으면 메모리직접접근.
그런데 아까 말했듯이, 가상메모리로 인해 데이터가 메모리가 아니라 디스크에 있을수도 있음.
이 때는 디스크->메모리로 다시 적재한 후 메모리를 읽어야함


현재는 빅데이터 시대이다. 이렇게 디스크만으로는 데이터 감당안될텐데 어떻게할까?
-> 분산 저장(여러대 쓰면 되지)
로컬의 디스크 데이터를 여러개로 분산되어있는 분산파일시스템에 저장
분산파일시스템은 데이터를 여러 서버에 분산해서 저장한다음 사용자가 통합된 파일 시스템처럼 접근할 수 있도록 해주는 시스템.

단일서버의 경우 고장날 시 데이터접근 안됨. 여러서버에 두면 복구가능
여러서버에 둠으로써 용량 확장가능하고 여러 유저들이 동시에 다른 서버에서 데이터 읽기/쓰기 가능.-> 속도 향상

kafka 의 경우 대용량메시지가 원격 분산 파일 시스템에 저장되어있지만, 실시간으로 해당 데이터의 소비자에게 전달됨.


캐시 친화적 프로그램

지역성의 원칙 : 프로그램이 규칙적으로 메모리에 접근한다

시간적 지역성 : 프로그램이 메모리조각에 접근하고 나서 여러 번 참조하는 경우

공간적 지역성 : 프로그램이 메모리조각 참조 시 그에 인접한 메모리도 참조하는 경우.
보통 인접된 데이터들은 자주 접근하게 될 가능성이 높다는 특성으로 같이 저장.
캐시 미스면 메모리의 데이터를 캐시에 적재해야되는데 이 때 일반적으로 요청한 메모리의 인접 데이터도 같이 캐시에 저장되므로 인접데이터 접근시 캐시히트 된다.


캐시 친화적 프로그래밍 원칙 몇 가지

1. 메모리 풀 사용

원래는 메모리조각이 힙영역의 이곳저곳에 흩어져 있을 가능성 높기 때문에 공간적 지역성이 안좋다.
메모리 풀 쓰면 미리 연속적인 공간을 할당받아서 여기에 집중적으로 모아놓을 수 있기 때문에 인접데이터의 접근이 용이함. -> 공간적 지역성이 훨씬 좋고, 캐시 적중률도 높아짐


2. struct 구조체 재배치

c언어에서 자주 사용되는 데이터가 메모리에 인접하게 배치되도록 해서 공간지역성을 최대한 활용하려고 하는것.
파이썬에서도 이와 비슷하게 데이터를 인접하게 배치하면 캐시 효율이 좋다.
예를들어 자주 접근되는 데이터를 리스트, 튜플 등에 인접하게 배치한다거나, 클래스 설계시 인스턴스 변수를 인접하게 배치하는 등.
리스트대신 배열모듈 or numpy 사용하면 메모리 배치가 효율적일 수 있음.
-> list를 쓰는거는 내부적으로 메모리의 위치를 가리키는 포인터임. 그래서 리스트 요소들이 메모리에서 연속된 공간에 배치되지 않을 수가 있음. array, numpy 등을 쓰면 메모리에서 연속된 공간에 데이터를 저장하기 때문에 캐시 효율을 높일 수 있다.

그럼 list말고 array,numpy로 다 쓰면 되는거 아닌가?
-> list는 동적크기 조정, 여러타입의 데이터 혼합사용가능, append, remove, sort 등 가능, 범용성 호환성 등의 장점이 있음.
array는 동일한 데이터타입의 요소가 들어가야하고 크기가 고정되어있음.
numpy도 동일한 데이터타입 요구되고, 다차원배열연산, 고속 벡터연산 등의 기능때문에 데이터분석, 머신러닝 등의 연산에서 유리.
but array,numpy는 유연성이 떨어짐

객체 크기 최소화하면 캐시에 더 많은 데이터 넣을 수 있어서 캐시효율 높일 수 있다.
파이썬 클래스의 __slots__ 쓰면 메모리 사용 최적화하기 좋음

class MyClass:
    __slots__ = ['x', 'y']  # 이 클래스는 'x'와 'y' 속성만 가질 수 있습니다.

    def __init__(self, x, y):
        self.x = x
        self.y = y

# 인스턴스 생성
obj = MyClass(10, 20)

print(obj.x, obj.y)  # 출력: 10 20

slots를 사용하면 특정 속성들만을 고정된 공간에 저장하도록 해서 메모리 사용량 줄이고 속성 접근 속도가 약간 더 빨라질 수 있음. 하지만 명시되지 않은 x,y외의 속성 추가 불가능한 단점.(유연성 떨어짐)

많은 수의 객체 생성해야할때, 고정된 수의 속성을 가지며 속성이 변하지 않을 때, 메모리 최적화필요 할 때 slots 사용하면 좋다.



3. 핫 데이터와 콜드데이터 분리

핫데이터 : 자주 접근되는 데이터. 캐시에 자주 들어감. 캐시히트율 높이기 위해 인접한 위치에 배치하는게 좋다

콜드데이터 : 드물게 접근되는 데이터. 캐시 들어가는 빈도가 낮고 메모리의 다른 부분에 따로 배치해서 캐시를 덜 차지하도록 하는게 좋다.


4. 캐시 친화적 데이터 구조

5. 다차원 배열 순회

다차원 배열 순회시 행 우선 탐색시 캐시 적중률이 높다.



이러한 원칙들은 캐시 적중률이 시스템 성능의 병목이 되는지 판단한 후에 적용하면 된다. 병목이 없다면 굳이 신경쓸 필요가 없다.


다중 스레드 성능 방해자

인접데이터 같이 저장 -> 묶음 데이터 저장 = '캐시라인' 이라고 부름

이 묶음의 크기는 보통 64바이트, 캐시 미스시 묶음 데이터가 캐시에 저장된다.


다중스레드가 단일스레드보다 느린 이유?

전역변수 a가 있다면 얘는 cpu1캐시, cpu2캐시에 모두 저장될것이다.
두 코어에 각각 스레드를 할당했다고 가정하고, 만약 각각 스레드가 모두 해당 변수에 +1을 해야한다면?
cpu1에의해 연산되어서 캐시에 저장된걸 무효화해야된다. -> 캐시 튕김

cpu1, cpu2가 각각 같은 변수를 연산하면서 캐시 일관성을 위해 계속 캐시를 무효화 시켜야해서 이 과정에서 성능저하가 일어남.
이 말은 곧 여러 스레드 사이에서 데이터 공유를 피하는게 좋다는 뜻.


거짓공유문제

위에서 말한것처럼 데이터 공유를 피하는게 좋은데, 사실 이 공유라는게 캐시라인 단위로 공유하기 떄문에 인접 데이터도 공유될 가능성이 높다.
-> 이게 거짓 공유 라는 문제다. 그래서 다른 변수임에도 같은 캐시라인 공유해서 캐시튕김 문제 생길 수 있음.
해결하는 방법으로 간단하게는 두 변수사이에 안쓰는 변수를 넣으면(64바이트 넘게끔) 각각 다른 캐시라인으로 위치하게 되어서 해결 가능. 이렇게 되면 다중 스레드 가 단일 스레드보다 빠르다.


다중 스레드 프로그램에 성능 병목현상 발생시 여러 테스트 해봤는데도 원인을 모르겠다면, 이와 같은 캐시 튕김 문제가 있는지 확인해봐야 한다.

0개의 댓글