기존 계산의 병목: 긴 의존성 체인
hour = time / 3600 , rem = time % 3600 , minute = rem / 60 , second = rem % 60 순서로 계산함 hour = time / 3600 , minute = (time / 60) % 60 , second = time % 60 으로 각 요소를 구함범위 제한 고정소수점과 Neri의 접근
/ 60 은 (u64)x * 71582789 >> 32 , / 3600 은 (u64)x * 1193047 >> 32 로 대체함 p1 = (u64)time * 71582789 에서 상위 32비트로 전체 분을, 하위 32비트를 71582789 로 나눈 값으로 초를 구함 [0..97612918] , 최대 결과는 27114:41:58 임V1: 두 나눗셈을 앞으로 옮겨 병렬화
tmin = time / 60 hour = time / 3600 second = time - tmin * 60 minute = tmin - hour * 60 tmin 과 hour 로 구하는 점이 핵심임 [0..2257198] , 최대 결과는 626:59:58 임 tmin = time / 60 , second = time % 60 , hour = tmin / 60 , minute = tmin % 60 순서로 계산함 time / 60 / 60 을 time / 3600 으로 바꾸면 V1이 되지만, 항상 수행되는 최적화는 아님 % 를 뺄셈·곱셈으로 바꿨을 때만 인식함V2: 상·하위 비트로 곱셈 네 번만 사용
hprd = (u64)time * 1193047 mlow = (u32)(time * 71582789) , hlow = (u32)hprd hour = hprd >> 32 minute = (u64)hlow * 60 >> 32 second = (u64)mlow * 60 >> 32 [0..2255818] , 최대 결과는 626:36:58 임 H_MUL , M_MUL 을 사용함 u128 을 사용하고, 상·하위 64비트로 같은 계산을 수행함V3: 60진법 시계를 64진법 나머지 연산으로 바꾸기
c = 4 를 적용해 % 64 를 사용함 tmin 과 hour 를 먼저 구함 second = (time + 4 * tmin) % 64 minute = (tmin + 4 * hour) % 64 [0..2257198] , 최대 결과는 626:59:58 임 lea eax, [ebx + 4*ecx] , 32비트 ARM은 ADD Rd, Rn, Rm, LSL #2 를 사용함 H_MUL , M_MUL 및 u128 곱셈을 사용하고, 나머지는 & 63 으로 구함 % 64 대신 & 63 을 써야 최대 속도를 유지할 수 있음 % 에는 음수 입력을 위한 보정이 필요함 (mlow >> 26) - (mlow >> 30) 으로 구하는 대안도 있음밀리초와 윤초로 확장
u32 전체 범위에 적용됨 tsec = time / 1000 , tmin = time / 60000 , hour = time / 3600000 milli = time - tsec * 1000 second = tsec - tmin * 60 minute = tmin - hour * 60 24:00:00 이 아니라 23:59:60 이어야 함 [0..86400] 에 다음 보정을 적용할 수 있음 is_leap = (time == 86400) hms = getHMS(time - is_leap) hms.second += is_leap tinc = time + 1 tmin = ((u64)tinc * 143163919) >> 33 hour = ((u64)tinc * 2386065) >> 33 second = (time + tmin * 4) % 64 , minute = (tmin + hour * 4) % 64SIMD는 넓은 곱셈보다 레인 수가 중요할 수 있음
hour = time * 37283 >> 27 tsec = time - hour * 3600 mins = tsec * 2185 >> 17 secs = tsec - mins * 60 [0..115199] , 최대 결과는 31:59:59 임벤치마크 결과와 구현 선택
time_test , time_bench 를 실행하며, -latency , -batch , -vector 모드를 지원함go로 테스트 코드를 만들어서 벤치를 돌려봤는데 본문 마지막에도 적혀있듯이 컴파일러가 최적화해서 차이가 없네요.
Lobste.rs 의견들
블로그 글을 쓰기 시작하고서야 함수 구조 변경 이 속도 향상의 가장 큰 원인이자, 기존 날짜 라이브러리들이 대체로 놓치고 있던 접근이라는 걸 깨달았음.
컴파일된 코드에 관해 안다고 생각하는 것 중 실제로 맞는 게 얼마나 될지 궁금해짐. 성능 때문에 불필요한 메모리 할당을 피하지만, 내가 모르는 미묘한 이유로 어떤 할당은 오히려 더 효율적일 수도 있지 않을까?
끝까지 따져 보면 컴파일러의 기계어 생성부터 GUI 라이브러리 구현까지 모든 추상화 계층 에서 같은 의문이 생김. 소프트웨어가 느린 것도 놀랍지 않음.
참고: https://en.wikipedia.org/wiki/Division_algorithm/… .
최적화 컴파일러에 이 기능이 들어가면, 이 글의 최적화도 대부분 별도 노력 없이 얻을 수 있을 것임.
처음에는 Go 코드를 Rust로 옮기고 필요한 부분을 수정하기가 아주 쉬웠음. 나중에 변경할 일이 생겨 다시 보니 Go 쪽 알고리즘이 거의 전부 최적화되어, 코드를 읽고 옮기기가 무척 어려워져 있었음. 예전에 대체 어떻게 이식했는지 한참 혼란스러워하다가 git 이력에서 이전 버전을 찾아봐야 한다는 걸 깨달았음.
그래서 깔끔하고 읽기 쉬운 수식을 원하면 "V1"을 선택하라 는 맺음말이 마음에 듦. 모든 상황에서 빠른 쪽이 더 좋다고 단정하지 않아서 좋음.
개선 사항이 비교적 독립적이니 2년, 5년, 10년 뒤 글을 갱신하고 날짜·시간 루틴을 교체하는 부담은 크지 않을 듯함. 과거 아키텍처와 당시 컴파일러 도구로도 시험해 보면 흥미로울 것 같음. 오늘의 최선이 어제도 최선이었는지, 나아가 내일도 최선일지 살펴볼 수 있음.
일반적으로 미세 최적화의 이점은 시간이 갈수록 줄어들지 않을까? 벤치마크 성능을 높이려는 하드웨어 설계자에게는 ‘단순하고 느린’ 방식이 더 눈에 잘 띄는 개선 대상이기 때문임. 물론 하드웨어가 이미 상당히 성숙했는데도 여전히 이런 최적화 기회가 나온다는 반론도 가능함.
더 넓은 비트 폭의 결과를 내는 곱셈을 지원하지 않는 아주 오래된 하드웨어라면 이 함수들 상당수가 제대로 성능을 내지 못할 것임. 다만 "NEON SIMD" 버전 은 그런 곱셈을 명시적으로 모두 피하므로 그 환경에서도 잘 작동할 가능성이 있음.
하드웨어의 미래는 예측하기 어려움. 누군가 곱셈을 3사이클 미만에 처리하는 기발한 방법을 찾거나, 시프트와 덧셈의 상대적 비용이 낮아지면 유불리가 달라질 것임. 가까운 미래에는 명령어 집합 구조(ISA)의 변화가 가장 큰 영향을 줄 수도 있음. RISC-V가 더 널리 쓰이면 다른 함수가 우세할 수도 있으니, 테스트용 기기를 하나 마련해야겠음.
단순한 코드가 예상 밖의 환경에서 더 빠를 수 있다는 데 동의함. AMD의 벡터 벤치마크에서는 "V1 Fixed-Pt"가 "V1"보다 느리지만 MacBook에서는 반대임. 이런 일이 일반적인 현상인지, 아니면 눈에 잘 띄는 예외인지는 모르겠음.
항상 주어진 API 만 사용하다가 이런 거 보면 정말 모르고 사용하는 게 많다고 느껴집니다. 읽기 즐거운 글이네요.