This commit is contained in:
Chris AtLee 2025-08-14 20:00:06 +03:00 committed by GitHub
commit 400f7105a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 514 additions and 0 deletions

View file

@ -0,0 +1,33 @@
prelude: |
# frozen_string_literal: true
# Benchmark for localtime cache performance
# Tests various access patterns that benefit from caching
# Sequential timestamps (common in log processing)
sequential_times = Array.new(100) { |i| Time.at(1609459200 + i) }
# Random timestamps (common in zip file processing)
random_times = Array.new(100) { |i| Time.at(1609459200 + (i * 1103515245 + 12345) % 86400) }
# Repeated timestamp (common in batch processing)
repeated_time = Time.at(1609459200)
benchmark:
# Sequential access pattern - benefits from cache
sequential: |
sequential_times.each { |t| t.localtime }
# Random access pattern - tests cache distribution
random: |
random_times.each { |t| t.localtime }
# Repeated access - maximum cache benefit
repeated: |
100.times { repeated_time.localtime }
# Mixed pattern - realistic workload
mixed: |
50.times do |i|
Time.at(1609459200 + i).localtime
Time.at(1609459200 + i * 3600).localtime
end

View file

@ -0,0 +1,27 @@
prelude: |
# frozen_string_literal: true
# Benchmark for localtime cache performance in forked processes
# This specifically tests the macOS fork performance issue
require 'benchmark'
def benchmark_localtime(count)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
count.times do |i|
Time.at(1609459200 + i % 10).localtime
end
Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
end
benchmark:
# Direct localtime calls (parent process)
parent_process: |
1000.times { |i| Time.at(1609459200 + i % 10).localtime }
# Localtime calls after fork (where cache provides most benefit)
forked_process: |
pid = fork do
# Perform localtime calls in child process
1000.times { |i| Time.at(1609459200 + i % 10).localtime }
end
Process.wait(pid)

View file

@ -2254,6 +2254,20 @@ AC_CHECK_FUNCS(waitpid)
AC_CHECK_FUNCS(__cospi)
AC_CHECK_FUNCS(__sinpi)
AC_ARG_ENABLE(macos-localtime-cache,
AS_HELP_STRING([--enable-macos-localtime-cache],
[enable localtime cache optimization for macOS to improve forked process performance]),
[macos_localtime_cache=$enableval], [macos_localtime_cache=yes])
AS_IF([test "$macos_localtime_cache" = yes], [
AS_CASE(["$target_os"],
[darwin*], [
AC_DEFINE(ENABLE_MACOS_LOCALTIME_CACHE, 1, [Enable macOS localtime cache optimization])
AC_MSG_NOTICE([macOS localtime cache optimization enabled])
],
[AC_MSG_WARN([--enable-macos-localtime-cache is only supported on macOS, ignoring])]
)
])
AS_IF([test "x$ac_cv_member_struct_statx_stx_btime" = xyes],
[AC_CHECK_FUNCS(statx)])

View file

@ -206,6 +206,247 @@ class TestTimeTZ < Test::Unit::TestCase
}
end if has_lisbon_tz
def test_dublin_mean_time
with_tz(tz="Europe/Dublin") {
# Dublin Mean Time had an offset of -00:25:21 (25 minutes 21 seconds behind UTC)
# from 1880 to 1916. Test creating times with this historical offset.
t = Time.new(1910, 1, 1, 0, 0, 0, "-00:25:21")
assert_equal(-1521, t.utc_offset, "UTC offset should be -1521 seconds")
assert_equal("-00:25:21", t.strftime("%::z"), "Formatted offset should be -00:25:21")
# Test that Ruby correctly handles the conversion to/from UTC
utc = Time.utc(1910, 1, 1, 0, 25, 21)
dublin = utc.getlocal("-00:25:21")
assert_equal(1910, dublin.year)
assert_equal(1, dublin.month)
assert_equal(1, dublin.day)
assert_equal(0, dublin.hour)
assert_equal(0, dublin.min)
assert_equal(0, dublin.sec)
assert_equal(-1521, dublin.utc_offset)
# Test arithmetic preserves the offset
t2 = t + 3600
assert_equal(-1521, t2.utc_offset, "Offset should be preserved after arithmetic")
}
end
def test_non_15minute_boundaries
# Test various times around non-15-minute boundaries with unusual offsets
# These test the caching mechanism's ability to handle offsets that don't
# align with the 15-minute cache boundaries
# Test -00:25:21 offset at various minutes/seconds
# This tests times that would fall into different 15-minute cache buckets
with_tz(tz="Europe/Dublin") {
# Test at 0:00 (cache bucket 0)
t1 = Time.new(1910, 1, 1, 0, 0, 0, "-00:25:21")
assert_equal(-1521, t1.utc_offset)
# Test at 0:14:59 (still cache bucket 0)
t2 = Time.new(1910, 1, 1, 0, 14, 59, "-00:25:21")
assert_equal(-1521, t2.utc_offset)
# Test at 0:15:00 (cache bucket 1)
t3 = Time.new(1910, 1, 1, 0, 15, 0, "-00:25:21")
assert_equal(-1521, t3.utc_offset)
# Test at 0:29:59 (still cache bucket 1)
t4 = Time.new(1910, 1, 1, 0, 29, 59, "-00:25:21")
assert_equal(-1521, t4.utc_offset)
# Test at 0:30:00 (cache bucket 2)
t5 = Time.new(1910, 1, 1, 0, 30, 0, "-00:25:21")
assert_equal(-1521, t5.utc_offset)
# Test at 0:44:59 (still cache bucket 2)
t6 = Time.new(1910, 1, 1, 0, 44, 59, "-00:25:21")
assert_equal(-1521, t6.utc_offset)
# Test at 0:45:00 (cache bucket 3)
t7 = Time.new(1910, 1, 1, 0, 45, 0, "-00:25:21")
assert_equal(-1521, t7.utc_offset)
# Test at 0:59:59 (still cache bucket 3)
t8 = Time.new(1910, 1, 1, 0, 59, 59, "-00:25:21")
assert_equal(-1521, t8.utc_offset)
}
# Test some other unusual historical offsets
# Amsterdam had +00:19:32 before 1892
t_ams = Time.new(1890, 6, 15, 12, 30, 45, "+00:19:32")
assert_equal(1172, t_ams.utc_offset, "Amsterdam offset should be 1172 seconds")
# Test arithmetic across cache boundaries with unusual offset
t_start = Time.new(1910, 1, 1, 0, 14, 30, "-00:25:21")
t_end = t_start + 90 # Move from bucket 0 to bucket 1
assert_equal(-1521, t_start.utc_offset)
assert_equal(-1521, t_end.utc_offset)
assert_equal(16, t_end.min)
assert_equal(0, t_end.sec)
end
def test_dublin_dst_1916_transition
# Test the critical DST transition in Dublin on May 21, 1916
# Before: -0:25:21 (DMT)
# After: -0:25:21 + 1:00 DST = +0:34:39 (IST)
with_tz(tz="Europe/Dublin") {
# Test before DST transition - different cache buckets
t1 = Time.local(1916, 5, 21, 1, 0, 0)
t2 = Time.local(1916, 5, 21, 1, 14, 59)
t3 = Time.local(1916, 5, 21, 1, 15, 0)
t4 = Time.local(1916, 5, 21, 1, 30, 0)
t5 = Time.local(1916, 5, 21, 1, 45, 0)
t6 = Time.local(1916, 5, 21, 1, 59, 59)
# All should have -0:25:21 offset before transition
assert_equal(-1521, t1.utc_offset, "1:00 should be -0:25:21")
assert_equal(-1521, t2.utc_offset, "1:14:59 should be -0:25:21")
assert_equal(-1521, t3.utc_offset, "1:15 should be -0:25:21")
assert_equal(-1521, t4.utc_offset, "1:30 should be -0:25:21")
assert_equal(-1521, t5.utc_offset, "1:45 should be -0:25:21")
assert_equal(-1521, t6.utc_offset, "1:59:59 should be -0:25:21")
# Test after DST transition - different cache buckets
t7 = Time.local(1916, 5, 21, 3, 0, 0)
t8 = Time.local(1916, 5, 21, 3, 14, 59)
t9 = Time.local(1916, 5, 21, 3, 15, 0)
t10 = Time.local(1916, 5, 21, 3, 30, 0)
t11 = Time.local(1916, 5, 21, 3, 45, 0)
t12 = Time.local(1916, 5, 21, 3, 59, 59)
# All should have +0:34:39 offset after transition
expected_offset = 2079 # -1521 + 3600 = 2079 seconds
assert_equal(expected_offset, t7.utc_offset, "3:00 should be +0:34:39")
assert_equal(expected_offset, t8.utc_offset, "3:14:59 should be +0:34:39")
assert_equal(expected_offset, t9.utc_offset, "3:15 should be +0:34:39")
assert_equal(expected_offset, t10.utc_offset, "3:30 should be +0:34:39")
assert_equal(expected_offset, t11.utc_offset, "3:45 should be +0:34:39")
assert_equal(expected_offset, t12.utc_offset, "3:59:59 should be +0:34:39")
# Verify DST flag
assert_equal(false, t1.dst?, "Before transition should not be DST")
assert_equal(true, t7.dst?, "After transition should be DST")
# Test times in October when DST ends
t13 = Time.local(1916, 10, 1, 1, 0, 0)
assert_equal(expected_offset, t13.utc_offset, "October 1 01:00 should still be DST")
assert_equal(true, t13.dst?, "October 1 01:00 should still be DST")
# After DST ends - Dublin transitions to GMT (0 offset), not back to DMT
t14 = Time.local(1916, 10, 1, 3, 0, 0)
assert_equal(0, t14.utc_offset, "October 1 03:00 should be GMT")
assert_equal(false, t14.dst?, "October 1 03:00 should not be DST")
}
end
def test_sub_minute_transitions
# Test timezone transitions that occur at non-standard seconds
# Initially we thought these would break the 15-minute cache, but the cache
# is actually keyed by UTC time, not local time, so it handles these correctly!
# Dublin October 1, 1916: Transitions at 02:25:21 local time
# From IST (+00:34:39) to GMT (+00:00:00)
# Times fall back from 02:59:59 IST to 02:25:21 GMT
with_tz(tz="Europe/Dublin") {
# The problematic case: times in the same 15-minute bucket with different offsets
# During the "fall back", times from 02:25:21 to 02:59:59 occur twice:
# - First as IST (before transition)
# - Then as GMT (after transition)
# Test the repeated hour during fall back
# 02:30:00 can be either IST or GMT
t_ist = Time.new(1916, 10, 1, 2, 30, 0, :dst) # Force DST=true (IST)
t_gmt = Time.new(1916, 10, 1, 2, 30, 0, :std) # Force DST=false (GMT)
# These have different offsets and fall in different UTC cache buckets:
# 02:30:00 IST = 01:55:21 UTC (bucket 01:45-01:59)
# 02:30:00 GMT = 02:30:00 UTC (bucket 02:30-02:44)
assert_equal(2079, t_ist.utc_offset, "02:30:00 IST should have offset +2079")
assert_equal(0, t_gmt.utc_offset, "02:30:00 GMT should have offset 0")
# Test more times in the same bucket
t_ist2 = Time.new(1916, 10, 1, 2, 44, 59, :dst) # Last second of bucket, IST
t_gmt2 = Time.new(1916, 10, 1, 2, 44, 59, :std) # Last second of bucket, GMT
assert_equal(2079, t_ist2.utc_offset, "02:44:59 IST should have offset +2079")
assert_equal(0, t_gmt2.utc_offset, "02:44:59 GMT should have offset 0")
}
# Monrovia January 7, 1972: Transitions at 00:44:30 local time
# From MMT (-00:44:30) to GMT (+00:00:00)
with_tz(tz="Africa/Monrovia") {
# Create UTC times that correspond to just before and after the transition
# 23:59:59 MMT (Jan 6) = 00:44:29 UTC (Jan 7) (offset -2670)
# 00:44:30 GMT (Jan 7) = 00:44:30 UTC (Jan 7) (offset 0)
utc_before = Time.utc(1972, 1, 7, 0, 44, 29)
utc_after = Time.utc(1972, 1, 7, 0, 44, 30)
# Convert to local times
local_before = utc_before.getlocal
local_after = utc_after.getlocal
# Check the times
assert_equal(6, local_before.day, "Before transition should be Jan 6")
assert_equal(23, local_before.hour, "Before transition should be hour 23")
assert_equal(59, local_before.min, "Before transition should be minute 59")
assert_equal(59, local_before.sec, "Before transition should be second 59")
assert_equal(7, local_after.day, "After transition should be Jan 7")
assert_equal(0, local_after.hour, "After transition should be hour 0")
assert_equal(44, local_after.min, "After transition should be minute 44")
assert_equal(30, local_after.sec, "After transition should be second 30")
# Check offsets
assert_equal(-2670, local_before.utc_offset, "23:59:59 should have MMT offset -2670")
assert_equal(0, local_after.utc_offset, "00:44:30 should have GMT offset 0")
# Test creating local times directly
# These are in the same cache bucket (00:30:00 - 00:44:59)
t1 = Time.local(1972, 1, 7, 0, 44, 29)
t2 = Time.local(1972, 1, 7, 0, 44, 30)
# With a 15-minute cache, these might incorrectly return the same offset
# The correct behavior is:
# Note: 00:44:29 doesn't exist in local time (it's during the skipped period)
# so Ruby might adjust it
assert_equal(0, t2.utc_offset, "Local 00:44:30 should have GMT offset 0")
}
end
def test_europe_astrakhan
# Test the Europe/Astrakhan timezone transition on Apr 30, 1924
# LMT offset: +3:12:12 (11532 seconds)
# At Wed Apr 30 20:47:47 1924 UT, time changes from LMT to +03
with_tz(tz="Europe/Astrakhan") {
# Test right before the transition
# Wed Apr 30 20:47:47 1924 UT = Wed Apr 30 23:59:59 1924 LMT
utc_before = Time.utc(1924, 4, 30, 20, 47, 47)
local_before = utc_before.getlocal
assert_equal(30, local_before.day)
assert_equal(4, local_before.month)
assert_equal(1924, local_before.year)
assert_equal(23, local_before.hour)
assert_equal(59, local_before.min)
assert_equal(59, local_before.sec)
assert_equal(11532, local_before.utc_offset, "Before transition should have LMT offset +3:12:12 (11532 seconds)")
# Test right after the transition
# Wed Apr 30 20:47:48 1924 UT = Wed Apr 30 23:47:48 1924 +03
utc_after = Time.utc(1924, 4, 30, 20, 47, 48)
local_after = utc_after.getlocal
assert_equal(30, local_after.day)
assert_equal(4, local_after.month)
assert_equal(1924, local_after.year)
assert_equal(23, local_after.hour)
assert_equal(47, local_after.min)
assert_equal(48, local_after.sec)
assert_equal(10800, local_after.utc_offset, "After transition should have +03 offset (10800 seconds)")
}
end
def test_pacific_kiritimati
with_tz(tz="Pacific/Kiritimati") {
assert_time_constructor(tz, "1994-12-30 00:00:00 -1000", :local, [1994,12,30,0,0,0])

199
time.c
View file

@ -18,6 +18,7 @@
#include <math.h>
#include <time.h>
#include <sys/types.h>
#include <stdint.h>
#ifdef HAVE_UNISTD_H
# include <unistd.h>
@ -703,6 +704,12 @@ static struct vtm *localtimew(wideval_t timew, struct vtm *result);
static int leap_year_p(long y);
#define leap_year_v_p(y) leap_year_p(NUM2LONG(modv((y), INT2FIX(400))))
static int calc_tm_yday(long tm_year, int tm_mon, int tm_mday);
static int calc_wday(int year_mod400, int month, int day);
#if defined(__APPLE__) && defined(ENABLE_MACOS_LOCALTIME_CACHE)
static void apply_tm_offset(struct tm *tm, long offset);
#endif
static VALUE tm_from_time(VALUE klass, VALUE time);
@ -746,6 +753,10 @@ get_tzname(int dst)
}
#endif
#if defined(__APPLE__) && defined(ENABLE_MACOS_LOCALTIME_CACHE)
static void invalidate_offset_cache(void);
#endif
void
ruby_reset_timezone(const char *val)
{
@ -754,6 +765,9 @@ ruby_reset_timezone(const char *val)
w32_tz.use_tzkey = !val || !*val;
#endif
ruby_reset_leap_second_info();
#if defined(__APPLE__) && defined(ENABLE_MACOS_LOCALTIME_CACHE)
invalidate_offset_cache();
#endif
}
static void
@ -764,12 +778,113 @@ update_tz(void)
tzset();
}
#if defined(__APPLE__) && defined(ENABLE_MACOS_LOCALTIME_CACHE)
/* Offset-based cache: stores UTC-to-local offset per 15-minute interval
* This handles leap seconds correctly since offset remains constant throughout a minute
* All timezone offsets are on 15-minute boundaries (00, 15, 30, 45) which improves
* cache effectiveness by 15x compared to per-minute caching */
#define OFFSET_CACHE_SIZE 64 /* Must be power of 2 - increased for better coverage */
#define OFFSET_CACHE_ZONE_LENGTH 64
/* Offset cache entry */
typedef struct {
time_t key;
long gmtoff; /* UTC to local offset in seconds */
int isdst; /* DST flag */
int valid;
#ifdef HAVE_STRUCT_TM_TM_ZONE
char* tm_zone;
#endif
} offset_cache_entry;
static offset_cache_entry offset_cache[OFFSET_CACHE_SIZE] = {{0}};
/* Invalidate the entire cache - called when timezone changes */
static void
invalidate_offset_cache(void)
{
for (int i = 0; i < OFFSET_CACHE_SIZE; i++) {
offset_cache[i].valid = 0;
}
}
/* Hash function for offset cache based on 15-minute intervals */
static inline unsigned int
offset_cache_hash(const time_t key)
{
/* Knuth multiplicative hash */
return (uint32_t)((key * 2654435761U) >> 26) & (OFFSET_CACHE_SIZE - 1);
}
#endif
static struct tm *
rb_localtime_r(const time_t *t, struct tm *result)
{
#if defined __APPLE__ && defined __LP64__
if (*t != (time_t)(int)*t) return NULL;
#endif
#if defined(__APPLE__) && defined(ENABLE_MACOS_LOCALTIME_CACHE)
/* First get UTC time components */
struct tm utc_tm;
if (!gmtime_r(t, &utc_tm)) {
return NULL;
}
/* Create cache key from UTC time aligned to one-minute boundaries */
time_t key = (*t) / (60);
if (*t < 0 && (*t) % (60) != 0) {
key--; /* Floor division adjustment for negative timestamps */
}
unsigned int cache_idx = offset_cache_hash(key);
offset_cache_entry *entry = &offset_cache[cache_idx];
/* Check cache */
if (entry->valid && entry->key == key) {
/* Cache hit - apply cached offset to UTC time */
*result = utc_tm; /* Start with UTC components */
apply_tm_offset(result, entry->gmtoff);
/* Set cached timezone info */
result->tm_isdst = entry->isdst;
#ifdef HAVE_STRUCT_TM_TM_GMTOFF
result->tm_gmtoff = entry->gmtoff;
#endif
#ifdef HAVE_STRUCT_TM_TM_ZONE
result->tm_zone = entry->tm_zone;
#endif
return result;
}
/* Cache miss - call localtime_r */
update_tz();
if (!localtime_r(t, result)) {
return NULL;
}
#ifdef HAVE_STRUCT_TM_TM_GMTOFF
long gmtoff = result->tm_gmtoff;
#else
/* Fallback: calculate offset if tm_gmtoff not available */
long gmtoff = mktime(result) - *t;
#endif
/* Store in cache as long as offset is a multiple of one minute */
if ((gmtoff % (60)) == 0) {
entry->key = key;
entry->gmtoff = gmtoff;
entry->isdst = result->tm_isdst;
entry->valid = 1;
#ifdef HAVE_STRUCT_TM_TM_ZONE
entry->tm_zone = result->tm_zone;
#endif
}
return result;
#else
update_tz();
#ifdef HAVE_GMTIME_R
result = localtime_r(t, result);
@ -779,6 +894,7 @@ rb_localtime_r(const time_t *t, struct tm *result)
if (tmp) *result = *tmp;
}
#endif
#endif /* __APPLE__ */
#if defined(HAVE_MKTIME) && defined(LOCALTIME_OVERFLOW_PROBLEM)
if (result) {
long gmtoff1 = 0;
@ -860,6 +976,88 @@ static const int8_t leap_year_days_in_month[] = {
#define days_in_month_in(y) days_in_month_of(leap_year_p(y))
#define days_in_month_in_v(y) days_in_month_of(leap_year_v_p(y))
#if defined(__APPLE__) && defined(ENABLE_MACOS_LOCALTIME_CACHE)
/* Apply offset to UTC time components */
static void
apply_tm_offset(struct tm *tm, long offset)
{
/* Break down offset into hours and minutes */
int offset_hours = (int)(offset / 3600);
int offset_mins = (int)((offset % 3600) / 60);
int offset_secs = (int)(offset % 60);
/* Apply seconds offset */
tm->tm_sec += offset_secs;
if (tm->tm_sec < 0) {
tm->tm_sec += 60;
offset_mins--;
}
else if (tm->tm_sec > 60) {
/* Preserve leap second (60) */
tm->tm_sec -= 60;
offset_mins++;
}
/* Apply minutes offset */
tm->tm_min += offset_mins;
if (tm->tm_min < 0) {
tm->tm_min += 60;
offset_hours--;
}
else if (tm->tm_min >= 60) {
tm->tm_min -= 60;
offset_hours++;
}
/* Apply hours offset */
tm->tm_hour += offset_hours;
int day_delta = 0;
if (tm->tm_hour < 0) {
day_delta = -((23 - tm->tm_hour) / 24);
tm->tm_hour = ((tm->tm_hour % 24) + 24) % 24;
}
else if (tm->tm_hour >= 24) {
day_delta = tm->tm_hour / 24;
tm->tm_hour = tm->tm_hour % 24;
}
/* Apply day offset if needed */
if (day_delta != 0) {
int year = tm->tm_year + 1900;
int month = tm->tm_mon + 1;
int day = tm->tm_mday + day_delta;
/* Handle month boundaries */
while (day < 1) {
month--;
if (month < 1) {
month = 12;
year--;
}
day += days_in_month_in(year)[month - 1];
}
while (day > days_in_month_in(year)[month - 1]) {
day -= days_in_month_in(year)[month - 1];
month++;
if (month > 12) {
month = 1;
year++;
}
}
/* Update tm structure */
tm->tm_year = year - 1900;
tm->tm_mon = month - 1;
tm->tm_mday = day;
/* Recalculate wday and yday */
tm->tm_wday = calc_wday(year % 400, month, day);
tm->tm_yday = calc_tm_yday(tm->tm_year, tm->tm_mon, tm->tm_mday);
}
}
#endif
#define M28(m) \
(m),(m),(m),(m),(m),(m),(m),(m),(m),(m), \
(m),(m),(m),(m),(m),(m),(m),(m),(m),(m), \
@ -951,6 +1149,7 @@ timegmw_noleapsecond(struct vtm *vtm)
divmodv(year1900, INT2FIX(400), &q400, &r400);
year_mod400 = NUM2INT(r400);
/* TODO - should this be year1900? */
yday = calc_tm_yday(year_mod400, vtm->mon-1, vtm->mday);
/*