Skip to content

Add unit fast paths to Calendar.ISO.iso_days_to_unit/2 - #15771

Merged
josevalim merged 1 commit into
elixir-lang:mainfrom
tomciopp:perf/calendar-identity-unit-conversion
Aug 20, 2026
Merged

Add unit fast paths to Calendar.ISO.iso_days_to_unit/2#15771
josevalim merged 1 commit into
elixir-lang:mainfrom
tomciopp:perf/calendar-identity-unit-conversion

Conversation

@tomciopp

Copy link
Copy Markdown
Contributor

Assisted-by: Claude Code:claude-fable-5

Continuing with the performance work, here is another case where we can short circuit.

System.convert_time_unit/3 delegates to :erlang.convert_time_unit/3, which is plain Erlang code, and has no same unit short circuit. It always executes:

(Time * integer_time_unit(ToUnit)) div integer_time_unit(FromUnit)

So we are:

  1. allocating a bignum for the product,
  2. running arbitrary precision multiplication into it
  3. Then running division to shrink it right back to the fixnum it started as
  4. Then throwing away the bignum as garbage.

Calendar.ISO.iso_days_to_unit/2 funnels every extraction through
System.convert_time_unit(total, :microsecond, unit). That call
(:erlang.convert_time_unit) has no same-unit short circuit: it computes
(total * to_ppd) div from_ppd, so the identity :microsecond conversion
hard-coded inside NaiveDateTime.diff/3 and DateTime.diff/3 multiplies a
~6.4e16 microsecond total by 1_000_000 — a ~6.4e22 bignum — and then divides
it right back. That happens twice per diff (three times for :day/:hour/
:minute, which route through :microsecond), and DateTime.to_unix/2 pays
the same shape for :millisecond.

This adds dedicated :second, :millisecond, and :microsecond clauses,
mirroring the sibling add_time_unit_to_iso_days/3 directly below it
(#15759). The System.convert_time_unit fallback is kept for :native,
:nanosecond, and integer units.

The sub-second clauses use the module's existing floor_div_positive_divisor/2
rather than div/2: :erlang.convert_time_unit rounds toward negative
infinity while div/2 truncates, and DateTime.to_unix/2 produces negative
totals for every pre-1970 datetime. Since to_unix/2 had no test coverage
with non-default units, this also adds a test pinning that boundary
(~U[1969-12-31 23:59:59.999999Z] is -1 in :millisecond; truncating
division would return 0).

Benchmark

Apple M5, macOS, OTP 29, JIT enabled. Averages over 3 s per job
(sub-microsecond medians quantize at the timer tick).

Job before after speedup
iso_days_to_unit(iso_days, :microsecond) (direct) 27.61 ns 4.29 ns 6.4x
Time.to_seconds_after_midnight/1 41.79 ns 24.27 ns 1.7x
DateTime.to_unix/1 (:second) 69.94 ns 44.36 ns 1.6x
DateTime.to_unix/1 pre-epoch (negative) 70.79 ns 48.05 ns 1.5x
DateTime.to_unix/2 (:millisecond) 91.43 ns 70.83 ns 1.3x
DateTime.diff/2 (:second, UTC) 138.49 ns 103.18 ns 1.3x
NaiveDateTime.diff/3 (:microsecond) 135.23 ns 96.14 ns 1.4x
NaiveDateTime.diff/2 (:second) 135.05 ns 113.56 ns 1.2x
NaiveDateTime.diff/3 (:day) 138.70 ns 118.24 ns 1.2x
iso_days_to_unit(iso_days, :native) (fallback control) 36.16 ns 37.47 ns flat
DateTime.to_unix/2 (:native, fallback control) 102.00 ns 101.99 ns flat
Benchmark script
Mix.install([{:benchee, "~> 1.4"}])

ndt1 = ~N[2026-08-20 12:34:56.123456]
ndt2 = ~N[2026-08-19 01:23:45.654321]
dt1 = DateTime.from_naive!(ndt1, "Etc/UTC")
dt2 = DateTime.from_naive!(ndt2, "Etc/UTC")
# Pre-epoch: iso_days_to_unit sees a negative total inside to_unix.
flamel = DateTime.from_naive!(~N[1418-03-22 08:02:25.527771], "Etc/UTC")
time = ~T[12:34:56.123456]

# Direct calls isolate the function from the diff/to_unix scaffolding.
iso_days = Calendar.ISO.naive_datetime_to_iso_days(2026, 8, 20, 12, 34, 56, {123_456, 6})

Benchee.run(
  %{
    "NaiveDateTime.diff second (2x identity µs)" => fn -> NaiveDateTime.diff(ndt1, ndt2) end,
    "NaiveDateTime.diff microsecond" => fn -> NaiveDateTime.diff(ndt1, ndt2, :microsecond) end,
    "NaiveDateTime.diff day (3x µs route)" => fn -> NaiveDateTime.diff(ndt1, ndt2, :day) end,
    "DateTime.diff second (UTC)" => fn -> DateTime.diff(dt1, dt2) end,
    "DateTime.to_unix second" => fn -> DateTime.to_unix(dt1) end,
    "DateTime.to_unix millisecond" => fn -> DateTime.to_unix(dt1, :millisecond) end,
    "DateTime.to_unix second pre-epoch (negative)" => fn -> DateTime.to_unix(flamel) end,
    "Time.to_seconds_after_midnight (days=0)" => fn -> Time.to_seconds_after_midnight(time) end,
    "iso_days_to_unit microsecond (direct)" => fn ->
      Calendar.ISO.iso_days_to_unit(iso_days, :microsecond)
    end,
    "iso_days_to_unit native (fallback control)" => fn ->
      Calendar.ISO.iso_days_to_unit(iso_days, :native)
    end,
    "DateTime.to_unix native (fallback control)" => fn -> DateTime.to_unix(dt1, :native) end
  },
  warmup: 1,
  time: 3,
  memory_time: 1
)

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Thomas Cioppettini <544875+tomciopp@users.noreply.github.com>
@josevalim

Copy link
Copy Markdown
Member

I also optimized the common path where the units are the same upstream: erlang/otp#11519

@josevalim
josevalim merged commit c4b42dd into elixir-lang:main Aug 20, 2026
15 checks passed
@josevalim

Copy link
Copy Markdown
Member

💚 💙 💜 💛 ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants