Fast civil_from_days with Julian Map and Packed Table
Converting a Unix epoch day back into a civil year, month, and day is commonly called civil_from_days. It is the inverse of the days_from_civil operation covered in the previous article.
civil date -- days_from_civil --> epoch day
civil date <-- civil_from_days --- epoch day
This article restricts output years to 1..9999 and combines Ben Joffe’s Julian-map year recovery with a 366-element packed month/day table. In Bun, two constant divisions are additionally replaced with multiplication by upward-rounded binary64 reciprocals.
In the Apple Silicon benchmarks reported here, the bounded implementation reduced execution time by approximately 87% in Bun, 74% in Rust, and 70% in C compared with the usual Howard Hinnant implementation.
Preconditions
The input range corresponding to civil years 1..9999 is:
-719162 <= epochDay <= 2932896
The endpoints are 0001-01-01 and 9999-12-31, respectively.
TypeScript Implementation
The Bun hot-path implementation returns the date packed into one integer:
packed = year * 512 + month * 32 + day
Months are in 1..12 and days are in 1..31, so the month and day fit in the low nine bits.
const INV_146097 = (1 / 146_097) * (1 + Number.EPSILON);
const INV_1461 = (1 / 1_461) * (1 + Number.EPSILON);
const MONTH_DAY = new Uint16Array(366);
for (let dayOfMarchYear = 0; dayOfMarchYear <= 365; dayOfMarchYear++) {
const n = 2_141 * dayOfMarchYear + 197_913;
const marchMonth = n >>> 16;
const day = Math.floor((n & 65_535) / 2_141) + 1;
const janOrFeb = dayOfMarchYear >= 306;
const month = janOrFeb ? marchMonth - 12 : marchMonth;
MONTH_DAY[dayOfMarchYear] =
(Number(janOrFeb) << 9) | (month << 5) | day;
}
/**
* Returns year * 512 + month * 32 + day.
* Preconditions: -719162 <= epochDay <= 2932896.
*/
export function civilFromDaysPacked(epochDay: number): number {
const q = ((epochDay << 2) + 2_877_875) | 0;
const century = (q * INV_146097) | 0;
const julian =
(q + ((century - (century >> 2)) << 2)) | 0;
const year = (julian * INV_1461) | 0;
const remainder =
(julian - Math.imul(year, 1_461)) | 0;
return (
(year << 9) + MONTH_DAY[remainder >>> 2]
) | 0;
}
If an ordinary object is needed, unpack it outside the hot loop:
export function unpackCivilDate(packed: number) {
return {
year: Math.floor(packed / 512),
month: (packed >>> 5) & 15,
day: packed & 31,
};
}
Allocating an object inside civilFromDaysPacked would make object allocation a significant part of the benchmark. Packing the result keeps the measurement focused on the calendar conversion itself.
Rust Implementation
Rust can generate the table at compile time. The constant divisions are left as /, allowing the compiler to perform its usual strength reduction.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CivilDate {
pub year: i32,
pub month: u32,
pub day: u32,
}
const fn build_month_day_table() -> [u16; 366] {
let mut table = [0u16; 366];
let mut r = 0usize;
while r <= 365 {
let n = 2_141 * r as u32 + 197_913;
let march_month = n >> 16;
let day = (n & 65_535) / 2_141 + 1;
let month = if r >= 306 {
march_month - 12
} else {
march_month
};
let year_bump = u16::from(r >= 306);
table[r] = (year_bump << 9) | ((month << 5) | day) as u16;
r += 1;
}
table
}
const MONTH_DAY: [u16; 366] = build_month_day_table();
#[inline]
pub fn civil_from_days(epoch_day: i32) -> CivilDate {
let q = 4 * (epoch_day + 719_468) as u32 + 3;
let century = q / 146_097;
let julian = q + century * 3 + (century & 3);
let year = julian / 1_461;
let day_of_march_year = julian % 1_461 / 4;
let packed = MONTH_DAY[day_of_march_year as usize] as u32;
CivilDate {
year: (year + (packed >> 9)) as i32,
month: (packed >> 5) & 15,
day: packed & 31,
}
}
C Implementation
In this C version, the initialization routine must run once at program startup. Production code can instead embed the generated values in a static const uint16_t[366], eliminating runtime initialization.
#include <stdint.h>
typedef struct {
int32_t year;
uint32_t month;
uint32_t day;
} civil_date_t;
static uint16_t month_day[366];
static void init_month_day_table(void) {
for (uint32_t r = 0; r <= 365; ++r) {
const uint32_t n = 2141u * r + 197913u;
const uint32_t march_month = n >> 16;
const uint32_t day = (n & 65535u) / 2141u + 1u;
const uint32_t month = r >= 306u
? march_month - 12u
: march_month;
month_day[r] = (uint16_t)(
((r >= 306u) << 9) | (month << 5) | day
);
}
}
static inline civil_date_t civil_from_days(int32_t epoch_day) {
const uint32_t q = 4u * (uint32_t)(epoch_day + 719468) + 3u;
const uint32_t century = q / 146097u;
const uint32_t julian = q + century * 3u + (century & 3u);
const uint32_t year = julian / 1461u;
const uint32_t day_of_march_year = julian % 1461u / 4u;
const uint32_t packed = month_day[day_of_march_year];
return (civil_date_t) {
(int32_t)(year + (packed >> 9)),
(packed >> 5) & 15u,
packed & 31u
};
}
The Usual civil_from_days
Howard Hinnant’s reference implementation can be written in TypeScript as follows:
function civilFromDaysHinnant(epochDay: number) {
const z = epochDay + 719_468;
const era = Math.floor(z / 146_097);
const dayOfEra = z - era * 146_097;
const yearOfEra = Math.floor(
(dayOfEra -
Math.floor(dayOfEra / 1_460) +
Math.floor(dayOfEra / 36_524) -
Math.floor(dayOfEra / 146_096)) /
365,
);
const year = yearOfEra + era * 400;
const dayOfYear =
dayOfEra -
(365 * yearOfEra +
Math.floor(yearOfEra / 4) -
Math.floor(yearOfEra / 100));
const marchMonth = Math.floor((5 * dayOfYear + 2) / 153);
const day =
dayOfYear - Math.floor((153 * marchMonth + 2) / 5) + 1;
const month = marchMonth + (marchMonth < 10 ? 3 : -9);
return {
year: year + (month <= 2 ? 1 : 0),
month,
day,
};
}
This is a clear, portable implementation with a broad year range. The bounded version trades that generality for speed over a fixed domain.
Recovering the Year with the Julian Map
The year-recovery stage uses Ben Joffe’s Julian map:
q = 4 * (epochDay + 719468) + 3
century = floor(q / 146097)
julian = q + 3 * century + (century & 3)
year = floor(julian / 1461)
rem = floor((julian - 1461 * year) / 4)
After correcting for the Gregorian 400-year cycle, the calculation temporarily maps the count into Julian four-year cycles. This shortens the dependency chain for recovering the year.
The original Joffe expression, q - (century & ~3) + 4 * century, reduces to the shorter expression above by using century = (century & ~3) + (century & 3).
Because 1461 = 4 * 365 + 1, julian / 1461 gives the March-based year, while the remaining value divided by four gives the day counted from March 1:
0 <= rem <= 365
January and February are the final two months of this March-based year. Storing the rem >= 306 year correction in bit 9 of the table removes the runtime comparison and addition.
Recovering Month and Day from a 366-Element Table
The arithmetic version recovers month and day with multiplication, shifts, division, and a January/February correction. Here, rem has only 366 possible values, from 0 through 365.
The corresponding year correction, month, and day are stored in a ten-bit integer:
(yearBump << 9) | (month << 5) | day
The lookup and decoding are:
const packed = MONTH_DAY[dayOfMarchYear];
const year = marchYear + (packed >>> 9);
const month = (packed >>> 5) & 15;
const day = packed & 31;
The table occupies:
366 * 2 = 732 bytes
That is small enough for L1 cache on ordinary PCs and servers. An arithmetic implementation may still be preferable with a cold cache, on embedded systems, or when code and data size matter more than hot-loop throughput.
Replacing Division with Upward-Rounded Reciprocals in Bun
In Bun, multiplication by precomputed reciprocals was faster than /146097 and /1461:
const INV_146097 = (1 / 146_097) * (1 + Number.EPSILON);
const INV_1461 = (1 / 1_461) * (1 + Number.EPSILON);
The resulting binary64 values are:
INV_146097 = 0.0000068447675174712704
bits = 0x3edcb5835e647c33
INV_1461 = 0.00068446269678302542
bits = 0x3f466db072f2284e
With the ordinary nearest binary64 reciprocal, an exactly divisible input can produce a product just below the expected integer, causing Math.floor to return one less. Multiplying by 1 + Number.EPSILON selects a neighboring value just above the exact reciprocal and avoids that failure.
For both divisors:
INV_d * d - 1 = 2^-52
The maximum quotients in this domain are approximately 100 and 10,000, so the upward errors are at most approximately 2.3e-14 and 2.3e-12. A nondivisible integer input remains at least 1/146097 or 1/1461 from the next quotient boundary. The upward error therefore cannot reach the next integer.
This argument depends on the specified input range and IEEE-754 binary64 arithmetic. A wider range requires a new proof and exhaustive verification.
Both products truncate to nonnegative integers below 2³¹ in this domain. The Bun implementation can therefore replace Math.floor with 32-bit integer coercion using | 0:
const century = (q * INV_146097) | 0;
const year = (julian * INV_1461) | 0;
The final implementation also folds the constant part of q and keeps the shifts, product, and return value in the int32 domain:
const q = ((epochDay << 2) + 2_877_875) | 0;
const remainder =
(julian - Math.imul(year, 1_461)) | 0;
For nonnegative century values:
3c + (c & 3) = 4 * (c - floor(c / 4))
The Bun implementation consequently uses this equivalent form:
const julian =
(q + ((century - (century >> 2)) << 2)) | 0;
This form helped in the tested Bun environment. Optimizing C and Rust compilers can already lower the original expression to an equivalent instruction sequence, so it is not assumed to be universally faster.
Four-Wide SIMD Without an Additional Table
When several dates can be converted together, month/day recovery can be returned to arithmetic and evaluated across four NEON lanes instead of enlarging the lookup table.
The January/February correction becomes one addition in the packed representation:
year += 1, month -= 12
packed += 512 - 12 * 32
= 128
The core can be expressed with Clang’s four-element vector type:
typedef uint32_t u32x4 __attribute__((ext_vector_type(4)));
static inline u32x4 civil_from_days_4(u32x4 epoch_day) {
const u32x4 q = epoch_day * 4u + 2877875u;
const u32x4 century = q / 146097u;
const u32x4 julian = q + century * 3u + (century & 3u);
const u32x4 year = julian / 1461u;
const u32x4 rem = (julian - year * 1461u) >> 2;
const u32x4 n = rem * 2141u + 197913u;
const u32x4 march_month = n >> 16;
const u32x4 day = (n & 65535u) / 2141u + 1u;
const u32x4 jan_feb = (u32x4)(rem >= 306u);
return year * 512u + march_month * 32u + day
+ (jan_feb & 128u);
}
Apple Clang with -O3 -march=native lowered this function to NEON instructions. Processing the same randomized input array, with automatic vectorization disabled for the scalar side, produced:
| Arithmetic version | ns/item | Relative throughput |
|---|---|---|
| Scalar | 2.694 | 1.00x |
| Four-wide SIMD | 0.713 | 3.78x |
The SIMD implementation matched Hinnant across all 3,652,059 epoch days. This is ARM64 batch throughput, not a like-for-like latency comparison with the one-item scalar API or packed-table implementation.
32-Bit Integer Bounds
Within the input domain:
306 <= epochDay + 719468 <= 3652364
1227 <= q <= 14609459
0 <= century <= 99
0 <= julian < 14610000
0 <= year <= 9999
0 <= dayOfMarchYear <= 365
The integer portion therefore fits comfortably in signed 32-bit values. Every MONTH_DAY entry, including the year bump, fits in ten bits, and the complete table occupies 732 bytes.
Exhaustive Verification
Every epoch day in the specified range was compared with the Hinnant implementation:
2932896 - (-719162) + 1
= 3,652,059 inputs
The tested candidates included Hinnant, Neri–Schneider, Ben Joffe, the Bun upward-reciprocal variant, and the packed-table variant. The final TypeScript, Rust, and C implementations all matched throughout the complete range.
The endpoints and Unix epoch were also checked directly:
-719162 -> 0001-01-01
0 -> 1970-01-01
2932896 -> 9999-12-31
Benchmarks
The benchmarks ran on Apple Silicon ARM64. Each implementation processed a deterministically generated array of 2^20 epoch days 24 times, rotating the input position between rounds. The best result from five trials was recorded.
Bun 1.4.0
rustc 1.96.0 -C opt-level=3 -C target-cpu=native
Apple clang 21.0.0 -O3 -march=native
Fresh measurements produced the following results:
| Environment | Usual Hinnant | Bounded version | Time reduction | Throughput |
|---|---|---|---|---|
| Bun | 37.088 ns/item | 4.830 ns/item | 87.0% | 7.68x |
| Rust | 5.734 ns/item | 1.500 ns/item | 73.8% | 3.82x |
| C | 5.315 ns/item | 1.603 ns/item | 69.8% | 3.32x |
In Bun, the version with a table-stored year bump but remaining double arithmetic took 6.091 ns/item, while the int32-domain final version took 4.830 ns/item. In addition to storing the year bump, folding constants and using Math.imul and shifts reduced transitions between double and int32 operations.
Rust and C compilers already lower division by constants to multiplication and shifts where profitable. Leaving integer division in the source was therefore appropriate in those languages. Replacing the month/day arithmetic dependency chain with the packed lookup produced the larger remaining improvement.
These measurements apply to one CPU, compiler/JIT version, and input distribution. Other Apple Silicon generations, x86-64, Node.js/V8, browsers, and different Rust or Clang versions should be measured independently.
Prior Work and Positioning
The year-recovery stage is based on Ben Joffe’s Julian map. Neri and Schneider systematically study Gregorian calendar conversion using multiplication, shifts, and Euclidean affine functions. Howard Hinnant’s implementation is the general reference baseline.
The Julian map, year recovery, strength reduction of constant division, and lookup of month/day from day-of-year are therefore not claimed as individually new techniques.
The construction presented here is a bounded specialization that:
- restricts output years to
1..9999; - recovers the year with Ben Joffe’s Julian map;
- maps the
0..365March-based day to a 732-byte packed table containing the year bump, month, and day; - uses range-proven upward-rounded binary64 reciprocals in Bun; and
- leaves constant-division strength reduction to the C and Rust compilers.
Among the public implementations examined, I did not find the same construction combining this fixed range, the Bun upward-rounded reciprocals, the Joffe mapping, and a packed month/day table. This does not establish optimality across all CPUs, languages, or input domains.
A precise performance claim is:
In these Apple Silicon benchmarks for Bun, Rust, and C, the bounded implementation was faster than the Hinnant, Neri–Schneider, and Ben Joffe implementations compared here.
Summary
Restricting the input to epoch days corresponding to years 1..9999 enables the following civil_from_days specialization:
- apply the Gregorian 400-year correction through the Julian map;
- recover the year and March-based day from a 1,461-day Julian cycle;
- recover the year bump, month, and day together from a 366-element packed table; and
- in Bun, replace two constant divisions with upward-rounded reciprocal multiplication.
In these Apple Silicon measurements, the bounded implementation reduced execution time by approximately 87% in Bun, 74% in Rust, and 70% in C compared with the usual Hinnant implementation. The table-free four-wide C SIMD arithmetic version additionally delivered approximately 3.78 times the batch throughput of its scalar arithmetic counterpart.
For general-purpose code, Hinnant’s implementation remains the clear and broadly ranged choice. If a table is undesirable, the arithmetic Neri–Schneider or Joffe implementations are strong alternatives. This bounded version is intended for a measured hot path where the range guarantee and 732-byte table are acceptable.
References
- Howard Hinnant, chrono-Compatible Low-Level Date Algorithms
- Cassio Neri and Lorenz Schneider, Euclidean Affine Functions and Applications to Calendar Algorithms
- Cassio Neri, EAF supplementary implementation
- Ben Joffe, The Julian Map
- Ben Joffe, A Very Fast 64-Bit Date Algorithm
- Ben Joffe, fast-date-benchmarks
- A Faster
days_from_civilUsing Magic-Number Division