Skip to content

Optimize Keyword.validate/2 by adding fast-track list merging#15499

Merged
josevalim merged 3 commits into
elixir-lang:mainfrom
preciz:optimization29
Jun 17, 2026
Merged

Optimize Keyword.validate/2 by adding fast-track list merging#15499
josevalim merged 3 commits into
elixir-lang:mainfrom
preciz:optimization29

Conversation

@preciz

@preciz preciz commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Assisted-by: Antigravity:gemini-3.5-flash

Faster in all cases and uses less memory in most cases.

We optimize Keyword.validate/2 by introducing a fast-path validate_merge/4 
that checks matching keys from the start of the list to avoid scanning the entire
remainder where possible. For mismatched keys, it falls back to validate_fallback/3. 
This improves performance on all input sizes and reduces memory usage.

Bench:

Mix.install([{:benchee, "~> 1.0"}])

defmodule OriginalKeyword do
  def validate(keyword, values) when is_list(keyword) and is_list(values) do
    validate(keyword, values, [], keyword, [])
  end

  defp validate([{key, _} | keyword], values1, values2, original, bad_keys) when is_atom(key) do
    case find_key!(key, values1, values2) do
      {values1, values2} ->
        validate(keyword, values1, values2, original, bad_keys)

      :error ->
        case find_key!(key, values2, values1) do
          {values1, values2} ->
            validate(keyword, values1, values2, original, bad_keys)

          :error ->
            validate(keyword, values1, values2, original, [key | bad_keys])
        end
    end
  end

  defp validate([], values1, values2, original, []) do
    {:ok, move_pairs!(values1, move_pairs!(values2, original))}
  end

  defp validate([], _values1, _values2, _original, bad_keys) do
    {:error, bad_keys}
  end

  defp validate([pair | _], _values1, _values2, _original, []) do
    raise ArgumentError,
          "expected a keyword list as first argument, got invalid entry: #{inspect(pair)}"
  end

  defp find_key!(key, [key | rest], acc), do: {rest, acc}
  defp find_key!(key, [{key, _} | rest], acc), do: {rest, acc}
  defp find_key!(key, [head | tail], acc), do: find_key!(key, tail, [head | acc])
  defp find_key!(_key, [], _acc), do: :error

  defp move_pairs!([key | rest], acc) when is_atom(key),
    do: move_pairs!(rest, acc)

  defp move_pairs!([{key, _} = pair | rest], acc) when is_atom(key),
    do: move_pairs!(rest, [pair | acc])

  defp move_pairs!([], acc),
    do: acc

  defp move_pairs!([other | _], _) do
    raise ArgumentError,
          "expected the second argument to be a list of atoms or tuples, got: #{inspect(other)}"
  end
end

defmodule OptimizedKeyword do
  def validate([], values) when is_list(values), do: {:ok, move_pairs!(values, [])}

  def validate(keyword, values) when is_list(keyword) and is_list(values) do
    validate_merge(keyword, values, [], keyword)
  end

  defp validate_merge([], values, values_pre, original),
    do: {:ok, move_pairs!(values, move_pairs!(values_pre, original))}

  defp validate_merge([{key, _} = pair | keyword], [head | tail], values_pre, original)
       when is_atom(key) do
    case head do
      ^key -> validate_merge(keyword, tail, values_pre, original)
      {^key, _} -> validate_merge(keyword, tail, values_pre, original)
      _ -> validate_merge([pair | keyword], tail, [head | values_pre], original)
    end
  end

  defp validate_merge([{key, _} | keyword], [], values_pre, original) when is_atom(key) do
    case find_key!(key, values_pre, []) do
      {new_values, new_values_pre} ->
        validate_merge(keyword, new_values, new_values_pre, original)

      :error ->
        validate_fallback(keyword, values_pre, [key])
    end
  end

  defp validate_merge([pair | _], _, _, _),
    do:
      raise(
        ArgumentError,
        "expected a keyword list as first argument, got invalid entry: #{inspect(pair)}"
      )

  defp validate_fallback([{key, _} | keyword], values, bad_keys)
       when is_atom(key) do
    case find_key!(key, values, []) do
      {rest, acc} ->
        validate_fallback(keyword, rest ++ acc, bad_keys)

      :error ->
        validate_fallback(keyword, values, [key | bad_keys])
    end
  end

  defp validate_fallback([], _, bad), do: {:error, bad}

  defp validate_fallback([p | _], _, _),
    do:
      raise(
        ArgumentError,
        "expected a keyword list as first argument, got invalid entry: #{inspect(p)}"
      )

  defp find_key!(key, [key | rest], acc), do: {rest, acc}
  defp find_key!(key, [{key, _} | rest], acc), do: {rest, acc}
  defp find_key!(key, [head | tail], acc), do: find_key!(key, tail, [head | acc])
  defp find_key!(_key, [], _acc), do: :error

  defp move_pairs!([key | rest], acc) when is_atom(key),
    do: move_pairs!(rest, acc)

  defp move_pairs!([{key, _} = pair | rest], acc) when is_atom(key),
    do: move_pairs!(rest, [pair | acc])

  defp move_pairs!([], acc),
    do: acc

  defp move_pairs!([other | _], _) do
    raise ArgumentError,
          "expected the second argument to be a list of atoms or tuples, got: #{inspect(other)}"
  end
end

inputs = %{
  "Small (3 schema keys, 0 override)" => {[], [one: 1, two: 2, three: 3]},
  "Medium (5 schema keys, 2 overrides)" => {[two: 22, four: 44], [one: 1, two: 2, three: 3, four: 4, five: 5]},
  "Large (20 schema keys, 10 overrides)" => {
    [k2: 22, k4: 44, k6: 66, k8: 88, k10: 100, k12: 120, k14: 140, k16: 160, k18: 180, k20: 200],
    Enum.map(1..20, fn i -> {:"k#{i}", i} end)
  },
  "Medium Out-of-order overrides" => {[four: 44, two: 22], [one: 1, two: 2, three: 3, four: 4, five: 5]},
  "Medium Invalid key (error case)" => {[two: 22, invalid: 99], [one: 1, two: 2, three: 3, four: 4, five: 5]}
}

Benchee.run(
  %{
    "original" => fn {kw, val} -> OriginalKeyword.validate(kw, val) end,
    "optimized" => fn {kw, val} -> OptimizedKeyword.validate(kw, val) end
  },
  inputs: inputs,
  memory_time: 2,
  time: 3,
  pre_check: :all_same
)

Results:

##### With input Large (20 schema keys, 10 overrides) #####
Name                ips        average  deviation         median         99th %
optimized        6.14 M      162.76 ns  ±4881.17%         130 ns         230 ns
original         3.52 M      284.27 ns  ±1848.00%         261 ns         391 ns

Comparison:
optimized        6.14 M
original         3.52 M - 1.75x slower +121.50 ns

Memory usage statistics:

Name         Memory usage
optimized           344 B
original            584 B - 1.70x memory usage +240 B

**All measurements for memory usage were the same**

##### With input Medium (5 schema keys, 2 overrides) #####
Name                ips        average  deviation         median         99th %
optimized       14.52 M       68.86 ns  ±6994.92%          60 ns          90 ns
original         9.73 M      102.77 ns  ±1475.49%         100 ns         150 ns

Comparison:
optimized       14.52 M
original         9.73 M - 1.49x slower +33.91 ns

Memory usage statistics:

Name         Memory usage
optimized           104 B
original            152 B - 1.46x memory usage +48 B

**All measurements for memory usage were the same**

##### With input Medium Invalid key (error case) #####
Name                ips        average  deviation         median         99th %
optimized       13.76 M       72.70 ns  ±5029.21%          60 ns         100 ns
original        10.57 M       94.59 ns  ±5304.58%          70 ns         150 ns

Comparison:
optimized       13.76 M
original        10.57 M - 1.30x slower +21.89 ns

Memory usage statistics:

Name         Memory usage
optimized           168 B
original            144 B - 0.86x memory usage -24 B

**All measurements for memory usage were the same**

##### With input Medium Out-of-order overrides #####
Name                ips        average  deviation         median         99th %
optimized       10.34 M       96.71 ns  ±4740.45%          80 ns         141 ns
original         8.33 M      120.06 ns  ±1841.07%         110 ns         160 ns

Comparison:
optimized       10.34 M
original         8.33 M - 1.24x slower +23.35 ns

Memory usage statistics:

Name         Memory usage
optimized           192 B
original            200 B - 1.04x memory usage +8 B

**All measurements for memory usage were the same**

##### With input Small (3 schema keys, 0 override) #####
Name                ips        average  deviation         median         99th %
optimized       18.00 M       55.56 ns  ±7589.65%          40 ns          60 ns
original        15.00 M       66.67 ns  ±6816.09%          50 ns          80 ns

Comparison:
optimized       18.00 M
original        15.00 M - 1.20x slower +11.11 ns

Memory usage statistics:

Name         Memory usage
optimized            72 B
original             72 B - 1.00x memory usage +0 B

**All measurements for memory usage were the same**

We optimize Keyword.validate/2 by introducing a fast-path validate_merge/4 that checks matching keys from the start of the list to avoid scanning the entire remainder where possible. For mismatched keys, it falls back to validate_fallback/3. This improves performance on all input sizes and reduces memory usage.

Assisted-by: Antigravity:gemini-3.5-flash
@sabiwara

sabiwara commented Jun 16, 2026

Copy link
Copy Markdown
Contributor
# Verify correctness first (must match EXACTLY, including order)

Always nice to check this 👍 Just FYI, you can now use pre_check: :all_same in recent versions of Benchee.

@preciz

preciz commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author
# Verify correctness first (must match EXACTLY, including order)

Always nice to check this 👍 Just FYI, you can now use pre_check: :all_same in recent versions of Benchee.

thx, I updated the bench with that, of course it has been extensively checked next to this benchmark that this is a non breaking change

Comment thread lib/elixir/test/elixir/keyword_test.exs Outdated
Comment thread lib/elixir/test/elixir/keyword_test.exs Outdated
Comment thread lib/elixir/test/elixir/keyword_test.exs
@josevalim josevalim merged commit 39acd23 into elixir-lang:main Jun 17, 2026
11 of 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.

3 participants