Skip to content

Optimize Calendar.ISO.parse_duration/1 - #15709

Merged
josevalim merged 1 commit into
elixir-lang:mainfrom
preciz:optimize-iso-parse-duration
Aug 1, 2026
Merged

Optimize Calendar.ISO.parse_duration/1#15709
josevalim merged 1 commit into
elixir-lang:mainfrom
preciz:optimize-iso-parse-duration

Conversation

@preciz

@preciz preciz commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Assisted-by: Codex CLI:GPT-5.6 Sol

Apply the leading duration sign as components are parsed, avoiding a second pass for negative durations.

Track allowed date and time units with ordered positions instead of keyword-list tails and recursive lookups.

Bench:

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2021 The Elixir Team

Mix.install([:benchee])

defmodule CalendarISOBefore do
  def parse_duration("P" <> string) when byte_size(string) > 0 do
    parse_duration_date(string, [], year: ?Y, month: ?M, week: ?W, day: ?D)
  end

  def parse_duration("+P" <> string) when byte_size(string) > 0 do
    parse_duration_date(string, [], year: ?Y, month: ?M, week: ?W, day: ?D)
  end

  def parse_duration("-P" <> string) when byte_size(string) > 0 do
    with {:ok, fields} <-
           parse_duration_date(string, [], year: ?Y, month: ?M, week: ?W, day: ?D) do
      {:ok,
       Enum.map(fields, fn
         {:microsecond, {value, precision}} -> {:microsecond, {-value, precision}}
         {unit, value} -> {unit, -value}
       end)}
    end
  end

  def parse_duration(_), do: {:error, :invalid_duration}

  defp parse_duration_date("", acc, _allowed), do: {:ok, acc}

  defp parse_duration_date("T" <> string, acc, _allowed) when byte_size(string) > 0 do
    parse_duration_time(string, acc, hour: ?H, minute: ?M, second: ?S)
  end

  defp parse_duration_date(string, acc, allowed) do
    with {integer, <<next, rest::binary>>} <- Integer.parse(string),
         {key, allowed} <- find_unit(allowed, next) do
      parse_duration_date(rest, [{key, integer} | acc], allowed)
    else
      _ -> {:error, :invalid_date_component}
    end
  end

  defp parse_duration_time("", acc, _allowed), do: {:ok, acc}

  defp parse_duration_time(string, acc, allowed) do
    case Integer.parse(string) do
      {second, <<delimiter, _::binary>> = rest} when delimiter in [?., ?,] ->
        with {:second, _allowed} <- find_unit(allowed, ?S),
             {{ms, precision}, "S"} <- parse_microsecond(rest) do
          ms =
            case string do
              "-" <> _ -> -ms
              _ -> ms
            end

          {:ok, [second: second, microsecond: {ms, precision}] ++ acc}
        else
          _ -> {:error, :invalid_time_component}
        end

      {integer, <<next, rest::binary>>} ->
        case find_unit(allowed, next) do
          {key, allowed} -> parse_duration_time(rest, [{key, integer} | acc], allowed)
          false -> {:error, :invalid_time_component}
        end

      _ ->
        {:error, :invalid_time_component}
    end
  end

  defp find_unit([{key, unit} | rest], unit), do: {key, rest}
  defp find_unit([_ | rest], unit), do: find_unit(rest, unit)
  defp find_unit([], _unit), do: false

  defp parse_microsecond("." <> rest) do
    case parse_microsecond(rest, 0, []) do
      {[], 0, _} ->
        :error

      {microsecond, precision, rest} ->
        {{:erlang.list_to_integer(microsecond) * scale_factor(precision), precision}, rest}
    end
  end

  defp parse_microsecond("," <> rest), do: parse_microsecond("." <> rest)
  defp parse_microsecond(rest), do: {{0, 0}, rest}

  defp parse_microsecond(<<head, tail::binary>>, 6, acc) when head in ?0..?9,
    do: parse_microsecond(tail, 6, acc)

  defp parse_microsecond(<<head, tail::binary>>, precision, acc) when head in ?0..?9,
    do: parse_microsecond(tail, precision + 1, [head | acc])

  defp parse_microsecond(rest, precision, acc), do: {:lists.reverse(acc), precision, rest}

  defp scale_factor(1), do: 100_000
  defp scale_factor(2), do: 10_000
  defp scale_factor(3), do: 1_000
  defp scale_factor(4), do: 100
  defp scale_factor(5), do: 10
  defp scale_factor(6), do: 1
end

defmodule CalendarISOAfter do
  def parse_duration("P" <> string) when byte_size(string) > 0 do
    parse_duration_date(string, 1, [], 0)
  end

  def parse_duration("+P" <> string) when byte_size(string) > 0 do
    parse_duration_date(string, 1, [], 0)
  end

  def parse_duration("-P" <> string) when byte_size(string) > 0 do
    parse_duration_date(string, -1, [], 0)
  end

  def parse_duration(_), do: {:error, :invalid_duration}

  defp parse_duration_date("", _sign, acc, _min_position), do: {:ok, acc}

  defp parse_duration_date("T" <> string, sign, acc, _min_position)
       when byte_size(string) > 0 do
    parse_duration_time(string, sign, acc, 0)
  end

  defp parse_duration_date(string, sign, acc, min_position) do
    with {integer, <<unit, rest::binary>>} <- Integer.parse(string),
         {key, next_min_position} <- find_date_unit(min_position, unit) do
      parse_duration_date(rest, sign, [{key, integer * sign} | acc], next_min_position)
    else
      _ -> {:error, :invalid_date_component}
    end
  end

  defp parse_duration_time("", _sign, acc, _min_position), do: {:ok, acc}

  defp parse_duration_time(string, sign, acc, min_position) do
    case Integer.parse(string) do
      {second, <<delimiter, _::binary>> = rest} when delimiter in [?., ?,] ->
        with {:second, _next_min_position} <- find_time_unit(min_position, ?S),
             {{ms, precision}, "S"} <- parse_microsecond(rest) do
          ms =
            case string do
              "-" <> _ -> -ms
              _ -> ms
            end

          {:ok, [second: second * sign, microsecond: {ms * sign, precision}] ++ acc}
        else
          _ -> {:error, :invalid_time_component}
        end

      {integer, <<unit, rest::binary>>} ->
        case find_time_unit(min_position, unit) do
          {key, next_min_position} ->
            parse_duration_time(rest, sign, [{key, integer * sign} | acc], next_min_position)

          false ->
            {:error, :invalid_time_component}
        end

      _ ->
        {:error, :invalid_time_component}
    end
  end

  defp find_date_unit(min_position, ?Y) when min_position <= 0, do: {:year, 1}
  defp find_date_unit(min_position, ?M) when min_position <= 1, do: {:month, 2}
  defp find_date_unit(min_position, ?W) when min_position <= 2, do: {:week, 3}
  defp find_date_unit(min_position, ?D) when min_position <= 3, do: {:day, 4}
  defp find_date_unit(_min_position, _unit), do: false

  defp find_time_unit(min_position, ?H) when min_position <= 0, do: {:hour, 1}
  defp find_time_unit(min_position, ?M) when min_position <= 1, do: {:minute, 2}
  defp find_time_unit(min_position, ?S) when min_position <= 2, do: {:second, 3}
  defp find_time_unit(_min_position, _unit), do: false

  defp parse_microsecond("." <> rest) do
    case parse_microsecond(rest, 0, []) do
      {[], 0, _} ->
        :error

      {microsecond, precision, rest} ->
        {{:erlang.list_to_integer(microsecond) * scale_factor(precision), precision}, rest}
    end
  end

  defp parse_microsecond("," <> rest), do: parse_microsecond("." <> rest)
  defp parse_microsecond(rest), do: {{0, 0}, rest}

  defp parse_microsecond(<<head, tail::binary>>, 6, acc) when head in ?0..?9,
    do: parse_microsecond(tail, 6, acc)

  defp parse_microsecond(<<head, tail::binary>>, precision, acc) when head in ?0..?9,
    do: parse_microsecond(tail, precision + 1, [head | acc])

  defp parse_microsecond(rest, precision, acc), do: {:lists.reverse(acc), precision, rest}

  defp scale_factor(1), do: 100_000
  defp scale_factor(2), do: 10_000
  defp scale_factor(3), do: 1_000
  defp scale_factor(4), do: 100
  defp scale_factor(5), do: 10
  defp scale_factor(6), do: 1
end

[
  time: 3,
  warmup: 1,
  inputs: %{
    "positive" => "P1Y2M3W4DT5H6M7.123456S",
    "negative" => "-P1Y2M3W4DT5H6M7.123456S"
  }
]
|> Benchee.init()
|> Benchee.system()
|> Benchee.benchmark("before", &CalendarISOBefore.parse_duration/1)
|> Benchee.benchmark("after", &CalendarISOAfter.parse_duration/1)
|> Benchee.collect()
|> Benchee.statistics()
|> Benchee.relative_statistics()
|> Benchee.Formatter.output()

Results:

Operating System: Linux
CPU Information: AMD Ryzen 7 8845HS w
Number of Available Cores: 16
Available memory: 54.72 GB
Elixir 1.20.2
Erlang 29.0.3
JIT enabled: true

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
reduction time: 0 ns
parallel: 1
inputs: negative, positive
Estimated total run time: 28 s
Excluding outliers: false

Benchmarking before with input negative ...
Benchmarking before with input positive ...
Benchmarking after with input negative ...
Benchmarking after with input positive ...
Calculating statistics...
Formatting results...

##### With input negative #####
Name             ips        average  deviation         median         99th %
after         1.70 M      589.16 ns  ±1099.95%         531 ns        1012 ns
before        1.28 M      782.92 ns   ±911.33%         721 ns        1343 ns

Comparison:
after         1.70 M
before        1.28 M - 1.33x slower +193.75 ns

##### With input positive #####
Name             ips        average  deviation         median         99th %
after         1.69 M      592.97 ns  ±1184.17%         531 ns        1032 ns
before        1.51 M      662.68 ns   ±974.33%         601 ns        1022 ns

Comparison:
after         1.69 M
before        1.51 M - 1.12x slower +69.71 ns

Apply the leading duration sign as components are parsed, avoiding a second pass for negative durations.

Track allowed date and time units with ordered positions instead of keyword-list tails and recursive lookups.

Assisted-by: Codex:GPT-5
@josevalim
josevalim merged commit 8c49052 into elixir-lang:main Aug 1, 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