Julian map と packed table による高速な civil_from_days


Unix epochからの通算日を年月日へ戻す処理は、一般に civil_from_days と呼ばれます。前の記事で扱った days_from_civil の逆変換です。

civil date -- days_from_civil --> epoch day
civil date <-- civil_from_days --- epoch day

この記事では出力年を 1..9999 に限定し、Ben JoffeのJulian mapによる年復元と、366要素のpacked month/day tableを組み合わせます。Bunではさらに、二つの定数除算を上方丸めしたbinary64逆数との乗算へ置き換えます。

Apple Silicon上の今回の測定では、実行時間が通常のHoward Hinnant実装の約13%(Bun)、26%(Rust)、30%(C)になりました。削減率ではそれぞれ約87%、74%、70%です。

前提

入力範囲は、暦年 1..9999 に対応する次のUnix epoch dayです。

-719162 <= epochDay <= 2932896

両端はそれぞれ 0001-01-019999-12-31 です。

TypeScript実装

Bun向けのホットパスは、年月日を一つの整数へpackして返します。

packed = year * 512 + month * 32 + day

月は 1..12、日は 1..31 なので、下位9 bitへ月日を格納できます。

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;
}

通常のオブジェクトが必要なら、ホットループの外で展開できます。

export function unpackCivilDate(packed: number) {
  return {
    year: Math.floor(packed / 512),
    month: (packed >>> 5) & 15,
    day: packed & 31,
  };
}

civilFromDaysPacked 自体にオブジェクト生成を含めると、カレンダー計算より割り当てコストを測る比率が大きくなるため、ベンチマークではpackした整数を返しています。

Rust実装

Rustではtableをコンパイル時に生成できます。定数除算はそのまま記述し、最適化をコンパイラに任せます。

#[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実装

Cでは次の初期化をプログラム開始時に一度実行します。本番用途では、生成結果を static const uint16_t[366] として埋め込めば初期化も不要です。

#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
    };
}

通常の civil_from_days

Howard Hinnantの基準実装をTypeScriptで書くと次のようになります。

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,
  };
}

これは広い年範囲を扱える、明快で移植性の高い実装です。今回の限定版は、この汎用性を固定範囲での速度と交換します。

Julian mapで年を復元する

今回の年復元はBen Joffeの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)

Gregorian calendarの400年周期を補正した後、一時的にJulian calendarの4年周期へ写すことで、年復元の依存鎖を短くします。

元のJoffe式 q - (century & ~3) + 4 * century は、century = (century & ~3) + (century & 3) を使うと、上の短い式へ整理できます。

1461 = 4 * 365 + 1 なので、julian / 1461 がMarch-basedの年を返し、余りを4で割った値がMarch 1から数えた年内日になります。

0 <= rem <= 365

1月と2月はMarch-based yearの末尾にあります。rem >= 306 の年補正をtableのbit 9へ格納することで、実行時の比較と加算も除去します。

月日を366要素のtableから取得する

通常は年内日から月日を復元するために、乗算、シフト、除算、1月・2月の補正を行います。しかし rem0..365 の366通りしかありません。

各値に対応する年補正、月、日を次の10-bit整数へ格納します。

(yearBump << 9) | (month << 5) | day

取得は次だけです。

const packed = MONTH_DAY[dayOfMarchYear];
const year = marchYear + (packed >>> 9);
const month = (packed >>> 5) & 15;
const day = packed & 31;

tableの大きさは、

366 * 2 = 732 bytes

です。一般的なPCのL1 cacheには十分小さい一方、cold cache、組み込み環境、コードサイズを重視する用途では算術版が有利な可能性があります。

Bunで除算を上方丸め逆数へ置き換える

Bunでは /146097/1461 より、あらかじめ求めた逆数との乗算が高速でした。

const INV_146097 = (1 / 146_097) * (1 + Number.EPSILON);
const INV_1461 = (1 / 1_461) * (1 + Number.EPSILON);

実際のbinary64値は次のとおりです。

INV_146097 = 0.0000068447675174712704
bits        = 0x3edcb5835e647c33

INV_1461   = 0.00068446269678302542
bits        = 0x3f466db072f2284e

通常の最も近いbinary64逆数では、割り切れる入力の積が整数よりわずかに小さくなり、Math.floor が1小さい値を返す場合があります。1 + Number.EPSILON を掛け、真の逆数よりわずかに大きい隣接値を使うことでこれを避けます。

どちらも、

INV_d * d - 1 = 2^-52

です。対象範囲の最大商は約100と10000なので、上方誤差はそれぞれ約 2.3e-142.3e-12 以下です。一方、割り切れない整数入力が次の商境界まで持つ距離は少なくとも 1/1460971/1461 です。したがって、上方誤差によって次の整数へ到達することはありません。

この議論は指定範囲とIEEE-754 binary64に依存します。範囲を広げる場合は再証明と再検証が必要です。

また、二つの積は非負で2³¹未満の整数へ切り捨てられるため、Bun版では Math.floor を32-bit整数化の | 0 に置き換えられます。

const century = (q * INV_146097) | 0;
const year = (julian * INV_1461) | 0;

最終版では、q の定数部分も畳み込み、シフト、積、戻り値をint32ドメインへ閉じています。

const q = ((epochDay << 2) + 2_877_875) | 0;
const remainder =
  (julian - Math.imul(year, 1_461)) | 0;

また、非負の century について、

3c + (c & 3) = 4 * (c - floor(c / 4))

なので、Bun版では次の別形を使います。

const julian =
  (q + ((century - (century >> 2)) << 2)) | 0;

これは今回のBunでは有効でしたが、最適化されたC/Rustではコンパイラが元の式をすでに同等の命令列へ変換できます。一般にこちらが速いと主張するものではありません。

追加tableなしの4-wide SIMD版

複数の日付をまとめて変換できる場合、732-byte tableをさらに大きくする代わりに、月日復元を算術式へ戻して4入力をNEONで並列処理できます。

January/February補正は、packed表現では次の一回の加算になります。

year += 1, month -= 12

packed += 512 - 12 * 32
        = 128

Clangの4要素vector型で中心部分を書くと次の形です。

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 Silicon/Clang -O3 -march=native ではNEON命令へ展開されました。同じランダム入力配列を処理し、scalar側の自動vectorizeを無効にしたbatch比較は次のとおりです。

算術版ns/件scalar比
scalar2.6941.00倍
4-wide SIMD0.7133.78倍

全3,652,059 epoch dayについてHinnant実装と一致しました。これはARM64向けbatch APIのスループットであり、1件を返すscalar APIやpacked table版のレイテンシと同一の比較ではありません。

32-bit整数の境界

入力範囲では、

306 <= epochDay + 719468 <= 3652364
1227 <= q <= 14609459
0 <= century <= 99
0 <= julian < 14610000
0 <= year <= 9999
0 <= dayOfMarchYear <= 365

となり、整数部分は符号付き32-bitへ十分収まります。year bumpを含む MONTH_DAY の各値は10 bit、table全体は732 bytesです。

全数検証

指定範囲のすべてのepoch dayについて、Hinnant実装と比較しました。

2932896 - (-719162) + 1
= 3,652,059 inputs

検証対象はHinnant、Neri–Schneider、Ben Joffe、Bunの上方逆数版、packed table版です。TypeScript、Rust、Cの最終実装は全件一致しました。

ベンチマーク

Apple Silicon ARM64上で、決定的な疑似乱数で生成した 2^20 件のepoch dayを24回処理しました。入力位置はラウンドごとに回転させています。5回測定した最小値です。

Bun 1.4.0
rustc 1.96.0 -C opt-level=3 -C target-cpu=native
Apple clang 21.0.0 -O3 -march=native

今回再測定した結果は次のとおりでした。

環境通常のHinnant今回の限定版実行時間削減率スループット
Bun37.088 ns/件4.830 ns/件87.0%7.68倍
Rust5.734 ns/件1.500 ns/件73.8%3.82倍
C5.315 ns/件1.603 ns/件69.8%3.32倍

Bunでは、year bumpをtableへ移しつつdouble演算を残した版が6.091 ns/件、int32ドメインへ閉じた最終版が4.830 ns/件でした。year bumpのtable化に加え、定数畳み込み、Math.imul、シフトを使ってdoubleとint32の往復を減らした効果が出ています。

RustとCでは、ソース上の定数除算をコンパイラが乗算とシフトへ変換できます。そのため、明示的な逆数より通常の整数除算表記が適切でした。packed tableによる月日復元の依存鎖削減が大きく効いています。

これらは特定のCPU、コンパイラ、JIT、入力分布での結果です。別世代のApple Silicon、x86-64、Node.js/V8、ブラウザ、別バージョンのRustやClangでは必ず再測定してください。

先行研究と今回の位置づけ

この実装の年復元はBen JoffeのJulian mapを利用しています。Gregorian calendar変換を乗算とシフトへ変換する方法は、NeriとSchneiderがEuclidean affine functionとして体系化しています。Howard Hinnantの実装は基準となる汎用アルゴリズムです。

したがって、Julian map、年復元、定数除算のstrength reduction、年内日tableのいずれも、単独の新規技法として主張するものではありません。

今回の構成は次の限定特殊化です。

  • 出力年を 1..9999 に限定する
  • Ben JoffeのJulian mapで年を復元する
  • 0..365 のMarch-based年内日を、year bumpも含む732-byteのpacked tableへ写す
  • Bunでは範囲証明した上方丸めbinary64逆数を使う
  • CとRustでは定数除算のstrength reductionをコンパイラに任せる

調査した公開実装の範囲では、この固定範囲、Bunの上方丸め逆数、Joffe式、packed month/day tableを組み合わせた同一構成は確認できませんでした。ただし、あらゆるCPU、言語、入力範囲で最速または最適であることを意味しません。

正確な表現は次のとおりです。

Apple Silicon上の今回のBun、Rust、Cベンチマークにおいて、比較したHinnant、Neri–Schneider、Ben Joffe実装より高速だった。

まとめ

1..9999 に対応するepoch dayへ範囲を限定すると、civil_from_days は次の構成で高速化できました。

  1. Julian mapでGregorianの400年補正を行う
  2. 1461日のJulian 4年周期から年とMarch-based年内日を復元する
  3. 366要素のpacked tableからyear bump、月、日を一度に取得する
  4. Bunでは二つの定数除算を上方丸め逆数との乗算へ置き換える

今回のApple Silicon測定では、実行時間が通常のHinnant実装の約13%(Bun)、26%(Rust)、30%(C)になりました。さらに、追加tableを使わないCの4-wide SIMD算術版は、scalar算術版の約3.78倍のbatch throughputでした。

汎用コードではHinnant実装、tableを避けるならNeri–SchneiderまたはJoffeの算術版が適しています。入力範囲が保証され、732-byte tableが許容でき、変換がホットパスにある場合に今回の限定版が候補になります。

参考資料