Skip to content

perf: Add exact fast paths to Calendar.ISO.add_day_fraction_to_iso_da… - #15759

Merged
josevalim merged 1 commit into
elixir-lang:mainfrom
tomciopp:perf/calendar-day-fraction-fast-path
Aug 16, 2026
Merged

perf: Add exact fast paths to Calendar.ISO.add_day_fraction_to_iso_da…#15759
josevalim merged 1 commit into
elixir-lang:mainfrom
tomciopp:perf/calendar-day-fraction-fast-path

Conversation

@tomciopp

Copy link
Copy Markdown
Contributor

…ys/3

The general clause multiplies both fractions up to a common
denominator (operands reaching ~10^16) and reduces with Integer.gcd
on every Time.add/3, NaiveDateTime.add/3, DateTime.add/4, and
timezone offset application. For every atom time unit the
denominators divide evenly, making gcd(ppd, add_ppd) the smaller
denominator, so the operation reduces exactly to one multiply-add.
Add clauses for both divides-evenly directions.

Also replace shift_time_unit/3 per-call
System.convert_time_unit(86_400, :second, unit) — a BIF recomputing
one of four constants — with pattern-matched clauses.

Benchee averages on Apple M1, Erlang/OTP 29 (medians quantize at the
timer tick at this scale): NaiveDateTime.add/3 with :millisecond
397 -> 292 ns, :second 332 -> 295 ns; Time.add/3 234 -> 188 ns;
DateTime.add/4 497 -> 458 ns. Memory is unchanged — the old
intermediates stayed within small integers, so the cost was purely
arithmetic. Allocation-free and exact by construction; the general
clause remains for calendars with arbitrary denominators.

Assisted by Claude Fable.

Job Before After Speedup
NaiveDateTime.add/3, :millisecond 397 ns 292 ns 1.36×
Time.add/3, :second 234 ns 188 ns 1.25×
NaiveDateTime.add/3, :second 332 ns 295 ns 1.13×
DateTime.add/4, :second (UTC) 497 ns 458 ns 1.09×
NaiveDateTime.add/3, :microsecond (control) 306 ns 288 ns ~6% (removed convert_time_unit BIF only)

@josevalim

Copy link
Copy Markdown
Member

There are two distinct optimizations here and I am not sure which ones are beneficial. Also, without knowing the inputs used in the benchmark, it is hard for me to say what is being measured and what is getting worse. Next time please:

  1. Include the benchmark scripts and inputs
  2. Try to limit the number of pull requests. If everyone in the Elixir community decides to have their agents opening several PRs per hour, then it will become impossible to accept external contributions

@josevalim josevalim closed this Aug 16, 2026
@tomciopp

Copy link
Copy Markdown
Contributor Author
Mix.install([{:benchee, "~> 1.4"}])

tag = System.get_env("TAG") || raise "set TAG, e.g. TAG=main or TAG=p13"
result_file = fn t -> Path.join(__DIR__, "p13_cal.#{t}.benchee") end
baselines = Path.wildcard(Path.join(__DIR__, "p13_cal.*.benchee")) -- [result_file.(tag)]

time = ~T[12:34:56.123456]
ndt = ~N[2026-08-16 12:34:56.123456]
dt = DateTime.from_naive!(ndt, "Etc/UTC")
dt2 = DateTime.add(dt, 3601, :second)

Benchee.run(
  %{
    "Time.add second" => fn -> Time.add(time, 3600, :second) end,
    "NaiveDateTime.add second" => fn -> NaiveDateTime.add(ndt, 3600, :second) end,
    "NaiveDateTime.add millisecond" => fn -> NaiveDateTime.add(ndt, 1500, :millisecond) end,
    "DateTime.add second (UTC)" => fn -> DateTime.add(dt, 3600, :second) end,
    "DateTime.compare" => fn -> DateTime.compare(dt, dt2) end,
    "NaiveDateTime.add microsecond (control)" => fn ->
      NaiveDateTime.add(ndt, 1500, :microsecond)
    end
  },
  warmup: 1,
  time: 3,
  memory_time: 1,
  save: [path: result_file.(tag), tag: tag],
  load: baselines
)

@josevalim

Copy link
Copy Markdown
Member

This is not a good benchmark because it only measures timestamps with microseconds precision.

@tomciopp

Copy link
Copy Markdown
Contributor Author

Following up on your benchmark point. Extending the suite across precisions led me somewhere useful: time_to_day_fraction/4 normalizes every precision to microsecond based parts, so the ISO calendar's
denominator is effectively a constant. That means this whole change simplifies to scaling the shift value at the call site and hitting the existing equal denominator clause. No new clauses in add_day_fraction_to_iso_days/3 at all. :nanosecond and integer units (which cannot be scaled to microseconds exactly) and custom calendars keep
the general gcd path unchanged.

Benchee medians: Time.add/3 :second 208 → 125 ns,
NaiveDateTime.add/3 :millisecond 375 → 250 ns, :second 291 → 250 ns;
results identical across timestamp precisions 0/3/6; memory unchanged;
full calendar suite passes.

-  def shift_time_unit({_days, _day_fraction} = iso_days, value, unit)
-      when unit in [:second, :millisecond, :microsecond, :nanosecond] or is_integer(unit) do
-    ppd = System.convert_time_unit(86_400, :second, unit)
-    add_day_fraction_to_iso_days(iso_days, value, ppd)
-  end
+  # This calendar's day fractions always carry microsecond parts (see
+  # time_to_day_fraction/4), so scaling the shift value to microseconds
+  # reaches the equal-denominator clause of add_day_fraction_to_iso_days/3
+  # directly, skipping the general gcd reduction. Nanosecond and integer
+  # units cannot be scaled to microseconds exactly, so they keep the
+  # general path, as do day fractions from other calendars, which fall
+  # back to the gcd clause on the denominator mismatch.
+  def shift_time_unit({_days, _day_fraction} = iso_days, value, :second) do
+    add_day_fraction_to_iso_days(iso_days, value * 1_000_000, @parts_per_day)
+  end
+
+  def shift_time_unit({_days, _day_fraction} = iso_days, value, :millisecond) do
+    add_day_fraction_to_iso_days(iso_days, value * 1_000, @parts_per_day)
+  end
+
+  def shift_time_unit({_days, _day_fraction} = iso_days, value, :microsecond) do
+    add_day_fraction_to_iso_days(iso_days, value, @parts_per_day)
+  end
+
+  def shift_time_unit({_days, _day_fraction} = iso_days, value, unit)
+      when unit == :nanosecond or is_integer(unit) do
+    ppd = System.convert_time_unit(86_400, :second, unit)
+    add_day_fraction_to_iso_days(iso_days, value, ppd)
+  end

Happy to open this as a fresh PR. Equally happy to drop it if it's still not worth the churn. Closing the loop either way.

Benchmark script (includes the precision twins)

Run once per checkout with a tag; the second run loads the first as a
baseline and prints comparisons. ~1 minute per run.

# calendar_add.exs
# Usage:
#   git checkout main && make compile
#   TAG=main bin/elixir calendar_add.exs
#
#   git checkout <branch> && make compile
#   TAG=candidate bin/elixir calendar_add.exs   # prints comparison vs main
Mix.install([{:benchee, "~> 1.4"}])

tag = System.get_env("TAG") || raise "set TAG, e.g. TAG=main"
result_file = fn t -> Path.join(__DIR__, "cal_add.#{t}.benchee") end
baselines = Path.wildcard(Path.join(__DIR__, "cal_add.*.benchee")) -- [result_file.(tag)]

time = ~T[12:34:56.123456]
ndt = ~N[2026-08-16 12:34:56.123456]
dt = DateTime.from_naive!(ndt, "Etc/UTC")
dt2 = DateTime.add(dt, 3601, :second)

# Precision twins: time_to_day_fraction normalizes every precision to
# microsecond-based parts, so these should measure identically to the
# precision-6 jobs. Included to verify that claim empirically.
ndt_p0 = ~N[2026-08-16 12:34:56]
ndt_p3 = ~N[2026-08-16 12:34:56.123]
time_p0 = ~T[12:34:56]

Benchee.run(
  %{
    "Time.add second" => fn -> Time.add(time, 3600, :second) end,
    "Time.add second (precision 0)" => fn -> Time.add(time_p0, 3600, :second) end,
    "NaiveDateTime.add second" => fn -> NaiveDateTime.add(ndt, 3600, :second) end,
    "NaiveDateTime.add second (precision 0)" => fn -> NaiveDateTime.add(ndt_p0, 3600, :second) end,
    "NaiveDateTime.add second (precision 3)" => fn -> NaiveDateTime.add(ndt_p3, 3600, :second) end,
    "NaiveDateTime.add millisecond" => fn -> NaiveDateTime.add(ndt, 1500, :millisecond) end,
    "NaiveDateTime.add millisecond (precision 0)" => fn ->
      NaiveDateTime.add(ndt_p0, 1500, :millisecond)
    end,
    "DateTime.add second (UTC)" => fn -> DateTime.add(dt, 3600, :second) end,
    "DateTime.compare" => fn -> DateTime.compare(dt, dt2) end,
    "NaiveDateTime.add microsecond (control)" => fn ->
      NaiveDateTime.add(ndt, 1500, :microsecond)
    end,
    "NaiveDateTime.add nanosecond (general path)" => fn ->
      NaiveDateTime.add(ndt, 1500, :nanosecond)
    end
  },
  warmup: 1,
  time: 3,
  memory_time: 1,
  save: [path: result_file.(tag), tag: tag],
  load: baselines
)

@josevalim

Copy link
Copy Markdown
Member

I have reopened this pull request, please push the changes above, those are good!

@josevalim josevalim reopened this Aug 16, 2026
@tomciopp
tomciopp force-pushed the perf/calendar-day-fraction-fast-path branch from 4463716 to 12cfc88 Compare August 16, 2026 08:14
@tomciopp

Copy link
Copy Markdown
Contributor Author

Pushed the proposed changes

@josevalim
josevalim merged commit 2db772e into elixir-lang:main Aug 16, 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