perf: Add exact fast paths to Calendar.ISO.add_day_fraction_to_iso_da… - #15759
Conversation
|
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:
|
|
|
This is not a good benchmark because it only measures timestamps with microseconds precision. |
|
Following up on your benchmark point. Extending the suite across precisions led me somewhere useful: Benchee medians: - 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)
+ endHappy 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 # 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
) |
|
I have reopened this pull request, please push the changes above, those are good! |
4463716 to
12cfc88
Compare
|
Pushed the proposed changes |
|
💚 💙 💜 💛 ❤️ |
…ys/3
The general clause multiplies both fractions up to a common
denominator (operands reaching ~10^16) and reduces with
Integer.gcdon every
Time.add/3,NaiveDateTime.add/3,DateTime.add/4, andtimezone offset application. For every atom time unit the
denominators divide evenly, making
gcd(ppd, add_ppd)the smallerdenominator, so the operation reduces exactly to one multiply-add.
Add clauses for both divides-evenly directions.
Also replace
shift_time_unit/3per-callSystem.convert_time_unit(86_400, :second, unit)— a BIF recomputingone 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.
NaiveDateTime.add/3,:millisecondTime.add/3,:secondNaiveDateTime.add/3,:secondDateTime.add/4,:second(UTC)NaiveDateTime.add/3,:microsecond(control)convert_time_unitBIF only)