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 versionns/itemRelative throughput
Scalar2.6941.00x
Four-wide SIMD0.7133.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:

EnvironmentUsual HinnantBounded versionTime reductionThroughput
Bun37.088 ns/item4.830 ns/item87.0%7.68x
Rust5.734 ns/item1.500 ns/item73.8%3.82x
C5.315 ns/item1.603 ns/item69.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..365 March-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:

  1. apply the Gregorian 400-year correction through the Julian map;
  2. recover the year and March-based day from a 1,461-day Julian cycle;
  3. recover the year bump, month, and day together from a 366-element packed table; and
  4. 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