Azure Native Qumulo 이제 EU, 영국 및 캐나다에서 사용 가능 – 자세히 알아보기

IOPS 성능 향상 및 잠금 효율성

작성자 :

오늘은 제가 흥미롭게 보았던 특정한 날의 작업에 대해 이야기하고자 합니다.

우리 팀은 최근 조사 중이었습니다. 읽기 성능 CPU 바운드로 표시된 작지만 수많은 요청에 대해. CPU 시간의 약 절반이 잠금을 놓고 경쟁하는 데 사용되었으며 더 많은 것을 확보하기 위해 최고의 경쟁자 목록을 작성하는 데 약간의 성공을 거두었습니다. IOPS. 우리가 이미 여러 영역에서 잠금 경합을 줄이는 작업을 했다는 점을 감안할 때 잠금 자체가 성능에 얼마나 많은 영향을 미치는지 궁금해지기 시작했습니다.

가장 일반적으로 사용되는 잠금 구현을 살펴보기 위해 시간을 할애했습니다. 내 생각은 모든 경합 잠금을 조금 더 빠르게 만들 수 있는 방법이 있는지 확인하는 것이었습니다. 두 가지 구현을 발견했습니다. 하나의 "spinlock"은 pthread_mutex용 래퍼이고 다른 하나는 덜 일반적으로 사용되는 futex용 래퍼입니다. pthread_mutex는 pthreads 라이브러리에서 제공하는 방대한 40바이트 구조인 반면 futex는 4바이트입니다. 나는 그들이 어떻게 수행되었는지에 대한 데이터를 얻기로 결정했지만 이미 futex 기반 구현을 최적화하고 더 표준으로 만드는 아이디어가 마음에 들기 시작했습니다.

실행하여 더 큰 변경을 수행하기 전에 두 잠금을 모두 벤치마킹하기로 결정했습니다. 내 경험에 따르면 성능 문제를 일으키는 대부분의 잠금은 크게 경합되는 경향이 있습니다. 각 스레드가 잠금을 획득하고 정수를 증가시키며 잠금을 해제하는 30개의 스레드를 스핀업하는 마이크로벤치마크를 작성했습니다. 또한 경쟁 없는 획득 속도의 오버헤드는 제품에서 감지하기가 매우 어려울 수 있다는 것을 알고 있으므로 단일 스레드 변형도 작성했습니다. 카운터를 확인하기 전에 각각을 XNUMX초 동안 실행했습니다. 초기 결과는 다음과 같습니다.

무승부 경합(4 스레드)
Pthread 뮤텍스 ~47ma/s* ~13ma/s*
퓨텍스 ~53ma/s* ~8.4ma/s*

* 백만 획득/초

실제 제품에 대한 빠른 테스트를 통해 이러한 결과를 검증했으며 여기서 Futex 구현을 위해 Pthreads 구현을 교체했습니다. 물론 Futex 구현은 성능을 저하시켰습니다. Pthread Mutex가 이 플랫폼의 Futex 위에 구현되어 있다는 사실을 알게 되었기 때문에 이것은 실망스러운 결과입니다. Futex 구현을 자세히 살펴보았습니다. 다음은 의사 코드의 중요한 부분입니다. 나는 퓨텍스가 어떻게 작동하는지에 대한 세부 사항을 의도적으로 단순화하고 있습니다. 왜냐하면 그것은 이 이야기와 크게 관련이 없기 때문입니다.

bool trylock(futex_lock *self) { return !atomic_bool_exchange(&self->is_locked, true); } 무효 잠금(futex_lock *self) { for (int i = 0; i < SOME_NUMBER; ++i) { if (trylock(self)) return; pause() // 스핀록 루프에 있음을 CPU에 알립니다. } while (true) { futex_sleep(&self->is_locked); if (trylock(self)) 반환; } } 무효 잠금 해제(futex_lock *self) { atomic_bool_set(&self->is_locked, false); futex_wake_all(&self->is_locked); }

여기에서 변경해야 할 가장 확실한 것은 SOME_NUMBER이고 일시 중지가 뒤따를 수 있습니다. 다른 숫자를 시도하고 일시 중지하는 방법에 대한 몇 가지 다른 아이디어를 몇 번 반복한 후 가장 효율적인 방법은 회전을 전혀 하지 않는 것임을 발견했습니다. 새로운 자물쇠는 이렇게 생겼습니다.

void lock(futex_lock *self) { while (true) { if (trylock(self)) return; futex_sleep(&self->is_locked); } }
1 스레드 4 스레드
Pthread 뮤텍스 ~47ma/s* ~13ma/s*
퓨텍스 ~53ma/s* ~8.4ma/s*
Futex(회전하지 않음) ~53ma/s* ~17ma/s*

* 백만 획득/초

futex에서 잠자는 것이 시스템 호출이고 따라서 꽤 비쌉니다. 회전하는 동안 다른 값비싼 일이 발생해야 합니다. Intel CPU에는 캐시 라인에 대한 세 가지 상태가 있습니다. "I"는 이 코어에 독점적인 액세스 권한이 있음을 의미합니다(쓰기에 안전). "S"는 캐시 라인이 깨끗하지만 다른 코어의 캐시에도 있을 수 있음을 의미합니다(읽기 안전). "A"는 다른 코어가 캐시 라인에 독점적으로 액세스할 수 있음을 의미합니다(묻지 않으면 안전하지 않음). 여러 코어가 동일한 캐시 라인의 데이터에 대해 원자적 액세스를 수행할 때 어떤 코어가 독점적 액세스 권한을 가지며 I와 A 상태 사이의 데이터 핑퐁을 교환합니다. 이것은 잠재적으로 CPU 소켓 간에 메시지를 브로드캐스트하는 것과 관련된 비용이 많이 드는 작업입니다.

이러한 배경에서 우리의 성능 향상은 매우 의미가 있습니다. 또한 최적화를 위한 몇 가지 더 많은 길을 열어줍니다. futex_wake_all은 여기서 특히 중요한 문제인 것 같습니다. 웨이터가 두 명 이상인 경우 한 웨이터만 잠금을 시도하는 데 성공하고 모든 웨이터는 캐시 라인에 대한 독점 액세스를 위해 싸우게 됩니다. 약간의 고민 끝에 다음과 같은 것을 생각해 냈습니다.

bool trylock(futex_lock *self) { auto old_state = atomic_uint_compare_exchange(&self->state, LOCK_FREE, LOCK_HELD); 반환 old_state == LOCK_FREE; } 무효 잠금(futex_lock *self) { auto old_state = atomic_uint_compare_exchange(&self->state, LOCK_FREE, LOCK_HELD); if (old_state == LOCK_FREE) return if (old_state == LOCK_HELD) old_state = atomic_uint_exchange(&self->state, LOCK_CONTENDED) while (old_state != spinlock_free) { futex_wait_if_value_equals(&self->state, LOCK_CONTENDED) old_state_ 상태, LOCK_CONTENDED) } } 무효 잠금 해제(futex_lock *self) { auto old_state = atomic_uint_exchange(&self->state, LOCK_FREE); if (old_state == LOCK_CONTENDED) futex_wake_one(&self->state) }

이 새로운 디자인은 경합하는 성능 측면에서 꽤 큰 장점을 가지고 있습니다. 새로운 경합 상태를 추가하여 한 번에 하나씩만 깨우도록 할 수 있습니다. 불필요하게 깨우기를 호출하는 것이 여전히 가능하지만 추가 깨우기에서 추가 캐시 라인 경합의 원인을 제거해야 합니다. 이는 마이크로벤치마크 성능의 추가 증가로 이어집니다.

1 스레드 4 스레드
Pthread 뮤텍스 ~47ma/s* ~13ma/s*
퓨텍스 ~53ma/s* ~8.4ma/s*
Futex(회전하지 않음) ~53ma/s* ~17ma/s*
퓨텍스(3-상태) ~53ma/s* ~20ma/s*

* 백만 획득/초

아직 개선해야 할 사소한 부분이 더 있는 것처럼 보이지만 Pthread Mutex보다 훨씬 앞서 있어 교체하면 실제 제품에서 약간의 이득을 볼 수 있습니다. 무작위 읽기 IOPS 벤치마크를 실행할 때 ~4% 향상되었습니다. 많지는 않지만 변경 사항은 제품에 대한 잠금 바인딩된 많은 워크로드에 걸쳐 매우 광범위한 영향을 미쳤습니다. 40->4바이트 크기 감소는 일부 시나리오에서 최대 기가바이트까지 총 램 소비를 줄였습니다.

관련 게시물

위쪽으로 스크롤