Ai! Jack!v2026.04.01-f12
← 홈GeekNews 인기

타임스탬프를 시·분·초로 변환하는 더 빠른 방법

원문 보기GeekNews에서 보기
15 pointsby neo

AI 분석

•시·분·초 변환의 순차 계산 의존성 → 계산 순서 변경만으로 대기 시간 단축 가능
•V1은 전체 분과 시간을 독립적으로 구한 뒤 초와 분을 병렬 계산하는 구조
•실무 최적화에서는 복잡한 비트 기법보다 계산 간 의존성 점검이 우선 검토 대상

검증 정보

출처
benjoffe.com
피드
GeekNews 인기
수집/분석 기준
2026. 9. 8. 오전 9:00
원문 확인
원문 열기

본문

•여러 주요 날짜/시간 라이브러리는 시/분/초를 구할 때 앞선 계산 결과를 차례로 기다리는 구조 를 사용하며, 계산 순서만 바꿔도 이 대기 시간을 크게 줄일 수 있음
• V1 은 전체 분과 시간을 독립적으로 구한 뒤 초와 분을 병렬로 계산해, 복잡한 비트 기법 없이 근사 지연시간을 기존 16사이클에서 8사이클로 줄임
• V2 는 고정소수점 곱셈의 상·하위 비트로 약 6사이클에 도달하고, V3 는 60진법 계산을 64진법 나머지 연산으로 바꿔 일부 대상에서 5사이클까지 단축함
•실제 성능은 CPU·컴파일러와 지연시간·처리량 중 무엇을 우선하는지 에 따라 달라지며, 밀리초 변환과 SIMD에도 별도의 최적화가 필요함
•가독성을 원하면 V1 , 높은 처리량과 짧은 지연시간을 함께 원하면 V2, 더 짧은 지연시간을 원하면 V3를 검토하되, 입력 범위와 윤초 처리 조건 을 확인하고 대상 환경에서 벤치마크해야 함

•요일 계산을 더 빠르게 만드는 방법 의 후속편으로, 이번에는 하루 동안 경과한 초를 시/분/초로 나누는 계산을 다룸. 앞선 글의 나머지 연산 기법을 확장하되, 계산 순서를 바꿔 연산 사이의 의존성을 줄이는 방법 부터 출발함

기존 계산의 병목: 긴 의존성 체인

•대상은 타임스탬프에서 날짜를 이미 분리한 뒤 남은 하루 내 초 단위 값` [0..86399]` 이며, 날짜 계산은 범위 밖임
• 전통적 방식 1 은 hour = time / 3600 , rem = time % 3600 , minute = rem / 60 , second = rem % 60 순서로 계산함
•Linux 커널 , glibc , Go 표준 라이브러리 , CPython datetime , OpenJDK java.time 이 사용함
•곱셈·시프트로 풀어낸 기계어 수준에서는 초가 분에, 분이 나머지에, 나머지가 시간에 의존해 계산을 겹치기 어려움
•여러 타임스탬프를 반복 처리하면 슈퍼스칼라 CPU가 서로 다른 입력의 연산을 겹칠 수 있어 처리량은 비교적 양호할 수 있음
• 전통적 방식 2 는 hour = time / 3600 , minute = (time / 60) % 60 , second = time % 60 으로 각 요소를 구함
•V8 , Boost , musl 이 사용하며, 요소마다 별도 함수를 두는 API 구조 때문에 선택하기도 함
•의존성 체인이 짧아져 지연시간은 개선되지만, 실제 계산 수가 8개에서 10개로 늘어 처리량은 비슷하거나 나빠짐
•사이클 비교는 정수 곱셈의 지연시간을 3사이클, 그 밖의 연산을 각각 1사이클 로 가정한 근사 모델이며, 모든 CPU에서 동일하게 측정되는 값은 아님
•이 모델에서 전통적 방식 1은 16사이클, 방식 2는 12사이클이며, 실제 벤치마크 관측과 강한 상관관계를 보임

범위 제한 고정소수점과 Neri의 접근

• 고정소수점 곱셈·시프트 로 나눗셈을 직접 대체하면 일부 프로세서에서 더 빨라짐
• / 60 은 (u64)x * 71582789 >> 32 , / 3600 은 (u64)x * 1193047 >> 32 로 대체함
•두 대체식이 함께 정확한 범위는 626시간 이상이므로 하루 내 입력에 사용할 수 있음
•x86·32비트 프로세서와 일부 SIMD에서 유리하며, 근사 모델상 전통적 방식 1과 2의 지연시간은 각각 14사이클과 10사이클로 줄어듦
• Cassio Neri의 2020년 방식 은 곱셈 결과의 상·하위 비트를 함께 활용함
•calendar 저장소 에 추가됐고, datealgo-rs 에도 구현됨
• p1 = (u64)time * 71582789 에서 상위 32비트로 전체 분을, 하위 32비트를 71582789 로 나눈 값으로 초를 구함
•전체 분에 같은 과정을 반복해 시와 분을 구하며, 유효 입력은 [0..97612918] , 최대 결과는 27114:41:58 임
•Neri의 방식은 Lemire 등의 2019년 연구 Faster Remainder by Direct Computation 의 변형을 사용함
•근사 지연시간은 10사이클 이며 대체로 전통적 방식보다 빠르지만, 대상 플랫폼에 따라 결과가 달라짐

V1: 두 나눗셈을 앞으로 옮겨 병렬화

• V1 은 다음 네 식으로 계산함
• tmin = time / 60
• hour = time / 3600
• second = time - tmin * 60
• minute = tmin - hour * 60
• 전체 분과 시간 을 동시에 계산하고, 이후 초와 분도 서로 의존하지 않게 계산함
•분을 원래 입력에서 직접 구하지 않고 두 중간 결과인 tmin 과 hour 로 구하는 점이 핵심임
•일반 나눗셈 버전은 입력 범위 제한이 없으며, 근사 지연시간은 8사이클임
•앞의 고정소수점 식으로 나눗셈을 바꾸면 7사이클로 줄어듦
•고정소수점 버전의 유효 입력은 [0..2257198] , 최대 결과는 626:59:58 임
•이러한 재배열은 이진수를 십진수 자릿수로 변환하는 등 진법 변환 에도 쓰이지만, 조사한 날짜 라이브러리에서는 같은 방식의 선례를 찾지 못함
• 전통적 방식 3 은 초부터 거꾸로 분해하며, .NET 런타임 이 V1에 가까운 이 방식을 사용함
• tmin = time / 60 , second = time % 60 , hour = tmin / 60 , minute = tmin % 60 순서로 계산함
•전통적 방식 2의 의존성 체인 깊이와 방식 1의 연산 수를 결합하며, 비트 기법 적용 전 Neri 방식과도 같음
•컴파일러가 time / 60 / 60 을 time / 3600 으로 바꾸면 V1이 되지만, 항상 수행되는 최적화는 아님
• 자동 재배열에 기대기보다 V1을 직접 쓰는 편이 유리함
•.NET JIT은 해당 최적화를 인식하지 못하는 것으로 보이며, MSVC도 테스트한 모든 경우에서 인식하지 못함
•Clang과 GCC는 % 를 뺄셈·곱셈으로 바꿨을 때만 인식함
•C++ 비교 코드는 Godbolt 에서 확인할 수 있음

V2: 상·하위 비트로 곱셈 네 번만 사용

• V2 — 고정소수점 상·하위 비트 는 V1의 병렬 구조에 곱셈 결과의 하위 비트를 이용하는 기법을 결합함
• hprd = (u64)time * 1193047
• mlow = (u32)(time * 71582789) , hlow = (u32)hprd
• hour = hprd >> 32
• minute = (u64)hlow * 60 >> 32
• second = (u64)mlow * 60 >> 32
•상수는 각각` floor(2^32 / 3600) + 1` 과` floor(2^32 / 60) + 1` 에서 나옴
•하위 32비트는 상위 정수값 사이에서 얼마나 진행됐는지를 나타내는 비율처럼 취급할 수 있음
•시간 계산의 하위 비트에 60을 곱하고 상위 비트를 취하면 경과한 분을 얻음
•나머지를 원래 나눗셈과 다른 단위로 추출하므로 Lemire 방식과 유사하지만 완전히 같지는 않음
•대부분의 대상에서 곱셈 네 번만으로 컴파일 되며, 병렬로 배치된 곱셈 두 단계 덕분에 근사 지연시간은 6사이클임
•유효 입력은 [0..2255818] , 최대 결과는 626:36:58 임
•높은 처리량과 매우 짧은 지연시간을 함께 얻을 수 있음
• 64비트 변형 은 같은 상수들을 왼쪽으로 32비트 이동한 H_MUL , M_MUL 을 사용함
•넓은 곱셈에는 u128 을 사용하고, 상·하위 64비트로 같은 계산을 수행함
•입력 유효 범위는 32비트 버전과 같음

V3: 60진법 시계를 64진법 나머지 연산으로 바꾸기

• V3 — 64진법 시계 기법 은 요일 계산에서 활용한 항등식 에 c = 4 를 적용해 % 64 를 사용함
•고정소수점으로 tmin 과 hour 를 먼저 구함
• second = (time + 4 * tmin) % 64
• minute = (tmin + 4 * hour) % 64
•유효 입력은 [0..2257198] , 최대 결과는 626:59:58 임
•` x + 4 y` 가 단일 사이클 명령으로 실행** 되는 대상에서는 근사 지연시간을 5사이클까지 줄임
•x86은 lea eax, [ebx + 4*ecx] , 32비트 ARM은 ADD Rd, Rn, Rm, LSL #2 를 사용함
•64비트 변형은 V2와 같은 H_MUL , M_MUL 및 u128 곱셈을 사용하고, 나머지는 & 63 으로 구함
• 부호 있는 정수 를 사용한다면 % 64 대신 & 63 을 써야 최대 속도를 유지할 수 있음
•부호 있는 % 에는 음수 입력을 위한 보정이 필요함
•V3는 V2보다 지연시간이 짧지만 전체 연산 수가 많아 처리량은 낮음
•지연시간과 처리량 중 무엇이 중요한지에 따라 두 버전을 직접 비교해야 함
•초를 (mlow >> 26) - (mlow >> 30) 으로 구하는 대안도 있음
•이 대안은 ARM64에서 32비트 버전을 사용할 때 조금 더 빠르지만 x86에서는 느리며, 별도 벤치마크 항목에는 포함되지 않음

밀리초와 윤초로 확장

• 밀리초 입력 도 몫을 먼저 독립적으로 계산해 의존성 체인을 끊을 수 있으며, 다음 방식은 u32 전체 범위에 적용됨
• tsec = time / 1000 , tmin = time / 60000 , hour = time / 3600000
• milli = time - tsec * 1000
• second = tsec - tmin * 60
• minute = tmin - hour * 60
•밀리초 방식에는 V3의 64진법 기법 을 적용할 수 있지만, V2의 상·하위 비트 기법을 적용하려면 64비트로 확장해야 함
•자동 벡터화에는 32비트 연산을 유지하는 편이 가장 유리함
•0.1초·0.01초·나노초 예제는 테스트 저장소 에 있음
•기본 알고리듬은 윤초가 없는 Unix 시간 에 적합함
•윤초가 있는 날의 마지막 초는 수학적으로 자연스러운 24:00:00 이 아니라 23:59:60 이어야 함
•윤초는 2035년까지 단계적으로 폐지될 예정이지만, 관련 코드를 곧바로 없앨 수 있는 것은 아님
• UTC+0에서 분기 없이 윤초를 처리 하려면 입력 [0..86400] 에 다음 보정을 적용할 수 있음
• is_leap = (time == 86400)
• hms = getHMS(time - is_leap)
• hms.second += is_leap
•이 방식에는 약 3사이클의 추가 비용이 듦
•추가 비용을 피하는 윤초 전용 고정소수점 기법 은 첫 오차가 마지막 윤초에 정확히 맞도록 계산함
• tinc = time + 1
• tmin = ((u64)tinc * 143163919) >> 33
• hour = ((u64)tinc * 2386065) >> 33
• second = (time + tmin * 4) % 64 , minute = (tmin + hour * 4) % 64
•아래로 반올림한 승수와 사전 증가를 조합해 마지막 분과 시간이 길어지게 함
•윤초 전용 식에는 형식적 증명이 없지만 하루 범위 전체를 전수 검사 함
•일반 시간과도 호환되며, 음의 윤초가 필요한 경우에도 사용할 수 있음

SIMD는 넓은 곱셈보다 레인 수가 중요할 수 있음

•앞선 최적화는 스칼라 성능 을 겨냥하며, SIMD의 최적 선택은 플랫폼에 크게 의존함
•일반적으로 32비트 버전은 64비트보다 레인 수가 두 배여서 유리함
•x86 AVX-512에서는 V1과 V3가 가장 좋은 SIMD 성능을 보임
• ARM Neon 전용 방식 은 전통적 방식 1을 수정해 나눗셈과 상위 32비트 결과를 모두 피함
• hour = time * 37283 >> 27
• tsec = time - hour * 3600
• mins = tsec * 2185 >> 17
• secs = tsec - mins * 60
•유효 입력은 [0..115199] , 최대 결과는 31:59:59 임
•ARM Neon의 128비트 SIMD 는 64비트 확장 결과가 없을 때 32비트 레인 네 개를 유지할 수 있음
•작은 승수와 시프트를 사용해 상위 32비트를 필요 없게 만드는 것이 처리량 향상의 핵심임
•Apple M-Series를 포함한 ARM Neon에서는 유리하지만, x86에서는 앞선 방식보다 훨씬 느림

벤치마크 결과와 구현 선택

•검증·벤치마크 저장소 는 명시된 입력 범위를 전수 검사하고, 메모리에 적재한 대량의 임의 날짜로 성능을 측정함
• 지연시간 모드 는 결과를 비트 XOR로 다음 계산에 전달해 입력 간 병렬 실행을 막음
•CMake Release 빌드 후 time_test , time_bench 를 실행하며, -latency , -batch , -vector 모드를 지원함
• 시·분·초 스칼라 성능 은 Neri 방식을 1.00으로 둔 상대 측정값이며, 숫자가 작을수록 빠름
•Apple M4 Pro·Apple clang 17.0.0에서 V3 64비트의 지연시간은 0.60배 , V2 64비트의 처리량 측정값은 0.57배임
•AMD Ryzen 9 9950X3D·Clang 18.1.3에서 V2·V3 64비트의 지연시간은 모두 0.49배이고, V2 64비트의 처리량 측정값은 0.44배임
•같은 Ryzen의 GCC 14.2.0에서는 V2·V3 64비트 지연시간이 0.60배, V2 64비트 처리량 측정값이 0.51배로 측정됨
•Raspberry Pi Zero·GCC 12.2.0에서는 V3 지연시간이 0.70배, V2 처리량 측정값이 0.74배임
• 벡터 벤치마크는 GCC로만 수행 함
•Clang은 벤치마크의 어떤 루프도 벡터화하지 않았으며, 원인은 추가 조사하지 않음
•전통적 방식 1을 기준으로 M4 Pro·GCC 15.2.0의 Neon 전용 방식은 0.25배임
•Ryzen·GCC 14.2.0에서는 V1이 0.26배, V3가 0.28배로 측정됨
•ARM에서 Neri 방식의 큰 측정값은 컴파일러가 제대로 벡터화하지 못했기 때문일 가능성이 매우 높음
• 밀리초 변환 의 새 방식은 전통적 방식 1 대비 M4 Pro와 Ryzen에서 지연시간 측정값이 0.48~0.63배, 처리량 측정값이 0.67~0.84배임
•Pi Zero에서는 지연시간이 1.02배로 개선되지 않았지만, 처리량 측정값은 0.70배로 줄어듦
• 윤초 전용 기법 은 전통적 방식 1에 표준 분기 없는 보정을 적용한 경우 대비, 네 환경에서 지연시간 측정값이 0.41~0.71배, 처리량 측정값이 0.48~0.70배임
•구현은 가독성·처리량·지연시간·대상 플랫폼 에 맞춰 선택해야 함
•읽기 쉬운 수학을 원하면 V1, 높은 처리량과 거의 최소인 지연시간을 원하면 V2, 더 낮은 지연시간을 원하면 V3가 적합함
•SIMD 데이터베이스 엔진에서는 대상에 따라 V1·V3·ARM Neon 방식을 검토할 수 있음
•성능이 중요하지 않다면 기존 구현을 유지해도 되지만, 부호 있는 나눗셈과 나머지 연산의 비용을 피하려면 부호 없는 정수를 사용하는 편이 좋음
•이상적으로는 컴파일러가 계산 재배열과 입력 범위 기반 최적화 를 수행할 수 있어야 하지만, 현재 컴파일러가 이를 모두 자동으로 처리하지는 못함
•이러한 최적화는 가장 빠른 시간 변환 코드를 만드는 데 그치지 않고, 다른 개발 영역에도 적용할 수 있는 계산 구조를 탐색하는 데도 가치가 있음

댓글 3개

rafina

go로 테스트 코드를 만들어서 벤치를 돌려봤는데 본문 마지막에도 적혀있듯이 컴파일러가 최적화해서 차이가 없네요.

neo

Lobste.rs 의견들

•설명이 명료하고 테스트도 충실함 . 내용 구성도 이해하기 쉽고 시각적으로 보기 좋음.
•이번 조사는 이전 요일 계산 글에서 파생된 60진법 → 64진법 기법 에서 시작했음. 이를 최대한 활용하려고 함수 구조를 여러 방식으로 바꿔 보게 됨.

블로그 글을 쓰기 시작하고서야 함수 구조 변경 이 속도 향상의 가장 큰 원인이자, 기존 날짜 라이브러리들이 대체로 놓치고 있던 접근이라는 걸 깨달았음.

•고수준 프로그래밍을 주로 하다 보니 이런 저수준 연산의 성능 특성 은 늘 막연하게 느껴짐. 나눗셈과 나머지 연산은 프로세서 명령어 하나로 처리되니 최적일 거라고 생각했는데, 이런 글을 보면 현실을 다시 깨닫게 됨.

컴파일된 코드에 관해 안다고 생각하는 것 중 실제로 맞는 게 얼마나 될지 궁금해짐. 성능 때문에 불필요한 메모리 할당을 피하지만, 내가 모르는 미묘한 이유로 어떤 할당은 오히려 더 효율적일 수도 있지 않을까?

끝까지 따져 보면 컴파일러의 기계어 생성부터 GUI 라이브러리 구현까지 모든 추상화 계층 에서 같은 의문이 생김. 소프트웨어가 느린 것도 놀랍지 않음.

• 프로파일러와 디버거 를 쓰면서 빠른 코드를 작성하는 법을 많이 배웠음.
•컴파일러는 상수로 나누기와 나머지 구하기를 곱셈으로 변환 할 수 있음. 그런데 같은 상수로 나눈 뒤 나머지를 구하는 연산을 곱셈 하나로 최적화하는 건 아직 보지 못했음. 누군가 이 댓글을 보고 아이디어를 얻는다면 시간문제일 듯함.

참고: https://en.wikipedia.org/wiki/Division_algorithm/… .

최적화 컴파일러에 이 기능이 들어가면, 이 글의 최적화도 대부분 별도 노력 없이 얻을 수 있을 것임.

•하루를 밤·아침·오후·저녁 네 구간으로만 나누는 구현이 필요해서 Go의` time.Time` 코드를 살펴봤던 때가 떠오름.

처음에는 Go 코드를 Rust로 옮기고 필요한 부분을 수정하기가 아주 쉬웠음. 나중에 변경할 일이 생겨 다시 보니 Go 쪽 알고리즘이 거의 전부 최적화되어, 코드를 읽고 옮기기가 무척 어려워져 있었음. 예전에 대체 어떻게 이식했는지 한참 혼란스러워하다가 git 이력에서 이전 버전을 찾아봐야 한다는 걸 깨달았음.

그래서 깔끔하고 읽기 쉬운 수식을 원하면 "V1"을 선택하라 는 맺음말이 마음에 듦. 모든 상황에서 빠른 쪽이 더 좋다고 단정하지 않아서 좋음.

•이번 글과 이 시리즈의 이전 글 모두 흥미롭게 읽었음. 단순한 접근법보다 가장 좋은 경우 지연 시간이 절반으로 줄어드는 정도면 상당한 개선인데, 이런 결과가 시간이 지나도 얼마나 유지될까?

개선 사항이 비교적 독립적이니 2년, 5년, 10년 뒤 글을 갱신하고 날짜·시간 루틴을 교체하는 부담은 크지 않을 듯함. 과거 아키텍처와 당시 컴파일러 도구로도 시험해 보면 흥미로울 것 같음. 오늘의 최선이 어제도 최선이었는지, 나아가 내일도 최선일지 살펴볼 수 있음.

일반적으로 미세 최적화의 이점은 시간이 갈수록 줄어들지 않을까? 벤치마크 성능을 높이려는 하드웨어 설계자에게는 ‘단순하고 느린’ 방식이 더 눈에 잘 띄는 개선 대상이기 때문임. 물론 하드웨어가 이미 상당히 성숙했는데도 여전히 이런 최적화 기회가 나온다는 반론도 가능함.

•이런 작업에서는 여러 종류의 하드웨어에 두루 적용되는 해법을 찾으려 함. 슈퍼스칼라 기능이 없는 Raspberry PI Zero 에서도 벤치마크하는 이유임. 오래된 하드웨어의 성능을 가늠할 대리 지표가 되길 바라지만, 추가 테스트 없이는 확신하기 어려움.

더 넓은 비트 폭의 결과를 내는 곱셈을 지원하지 않는 아주 오래된 하드웨어라면 이 함수들 상당수가 제대로 성능을 내지 못할 것임. 다만 "NEON SIMD" 버전 은 그런 곱셈을 명시적으로 모두 피하므로 그 환경에서도 잘 작동할 가능성이 있음.

하드웨어의 미래는 예측하기 어려움. 누군가 곱셈을 3사이클 미만에 처리하는 기발한 방법을 찾거나, 시프트와 덧셈의 상대적 비용이 낮아지면 유불리가 달라질 것임. 가까운 미래에는 명령어 집합 구조(ISA)의 변화가 가장 큰 영향을 줄 수도 있음. RISC-V가 더 널리 쓰이면 다른 함수가 우세할 수도 있으니, 테스트용 기기를 하나 마련해야겠음.

단순한 코드가 예상 밖의 환경에서 더 빠를 수 있다는 데 동의함. AMD의 벡터 벤치마크에서는 "V1 Fixed-Pt"가 "V1"보다 느리지만 MacBook에서는 반대임. 이런 일이 일반적인 현상인지, 아니면 눈에 잘 띄는 예외인지는 모르겠음.

hwhang0917

항상 주어진 API 만 사용하다가 이런 거 보면 정말 모르고 사용하는 게 많다고 느껴집니다. 읽기 즐거운 글이네요.

관련 아이템