Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize HTTP/2 request creation #423

Merged

Conversation

the-mikedavis
Copy link
Contributor

There is some low-hanging fruit to improve the time it takes to open a new request with HTTP/2 by avoiding URI.default_port/1 and String.upcase/1 calls.

With some very un-scientific :timer.tc/1 benchmarking I see these changes reduce the time it takes to complete a Mint.HTTP2.request/5 call (passing :stream as the data argument) from 12.8ms to 2.5ms.

| headers
]
end
end

@spec is_method?(proposed :: binary(), method :: charlist()) :: boolean()
defp is_method?(<<>>, []), do: true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some benchmarking here to try to find the right replacement for the equality check:

Benchee script...
Mix.install([:benchee])
Code.ensure_loaded(String)

defmodule Helper do
  @spec is_method?(proposed :: binary(), method :: charlist()) :: boolean()
  def is_method?(<<>>, []), do: true

  def is_method?(<<char, rest_bin::binary>>, [char | rest_list]),
    do: is_method?(rest_bin, rest_list)

  def is_method?(<<lower_char, rest_bin::binary>>, [char | rest_list])
      when lower_char >= ?a and lower_char <= ?z and lower_char - 32 == char do
    is_method?(rest_bin, rest_list)
  end

  def is_method?(_proposed, _method), do: false

  @spec is_method_2?(proposed :: binary(), method :: binary()) :: boolean()
  def is_method_2?(method, method), do: true

  def is_method_2?(<<char, rest_proposed::binary>>, <<char, rest_method::binary>>),
    do: is_method_2?(rest_proposed, rest_method)

  def is_method_2?(<<lower_char, rest_proposed::binary>>, <<char, rest_method::binary>>)
      when lower_char >= ?a and lower_char <= ?z and lower_char - 32 == char do
    is_method_2?(rest_proposed, rest_method)
  end

  def is_method_2?(_proposed, _method), do: false
end

source = "CONNECT"

Benchee.run(
  %{
    "String.upcase/1" => fn ->
      String.upcase(source) == "CONNECT"
    end,
    "String.upcase(source, :ascii)" => fn ->
      String.upcase(source, :ascii) == "CONNECT"
    end,
    "Helper.is_method?" => fn ->
      Helper.is_method?(source, ~c"CONNECT")
    end,
    "Helper.is_method_2?" => fn ->
      Helper.is_method_2?(source, ~c"CONNECT")
    end
  },
  warmup: 3,
  time: 15,
  memory_time: 15
)
Results...
source = "POST"
---
Name                                    ips        average  deviation         median         99th %
Helper.is_method?                    6.99 M      143.04 ns ±40533.83%         100 ns         230 ns
Helper.is_method_2?                  4.85 M      206.05 ns ±34158.32%         130 ns         401 ns
String.upcase(source, :ascii)        3.76 M      265.78 ns ±21955.37%         190 ns         501 ns
String.upcase/1                      3.57 M      280.20 ns ±18536.86%         221 ns         541 ns

Comparison: 
Helper.is_method?                    6.99 M
Helper.is_method_2?                  4.85 M - 1.44x slower +63.00 ns
String.upcase(source, :ascii)        3.76 M - 1.86x slower +122.73 ns
String.upcase/1                      3.57 M - 1.96x slower +137.16 ns

Memory usage statistics:

Name                             Memory usage
Helper.is_method?                        40 B
Helper.is_method_2?                      64 B - 1.60x memory usage +24 B
String.upcase(source, :ascii)           128 B - 3.20x memory usage +88 B
String.upcase/1                         192 B - 4.80x memory usage +152 B



source = "connect"
---
Name                                    ips        average  deviation         median         99th %
Helper.is_method?                    6.18 M      161.80 ns ±34822.53%         130 ns         230 ns
Helper.is_method_2?                  4.79 M      208.91 ns ±33755.96%         161 ns         411 ns
String.upcase(source, :ascii)        3.20 M      312.49 ns ±21079.05%         221 ns         521 ns
String.upcase/1                      2.69 M      371.45 ns ±17205.79%         241 ns        1733 ns

Comparison: 
Helper.is_method?                    6.18 M
Helper.is_method_2?                  4.79 M - 1.29x slower +47.11 ns
String.upcase(source, :ascii)        3.20 M - 1.93x slower +150.69 ns
String.upcase/1                      2.69 M - 2.30x slower +209.66 ns

Memory usage statistics:

Name                             Memory usage
Helper.is_method?                        40 B
Helper.is_method_2?                      64 B - 1.60x memory usage +24 B
String.upcase(source, :ascii)           176 B - 4.40x memory usage +136 B
String.upcase/1                         288 B - 7.20x memory usage +248 B



source = "CONNECT"
---
Name                                    ips        average  deviation         median         99th %
Helper.is_method?                    6.62 M      150.99 ns ±19398.92%         120 ns         241 ns
Helper.is_method_2?                  4.95 M      202.14 ns ±25693.65%         160 ns         411 ns
String.upcase(source, :ascii)        3.26 M      307.22 ns ±22254.21%         220 ns         520 ns
String.upcase/1                      2.77 M      360.69 ns ±13072.92%         241 ns        1753 ns

Comparison: 
Helper.is_method?                    6.62 M
Helper.is_method_2?                  4.95 M - 1.34x slower +51.14 ns
String.upcase(source, :ascii)        3.26 M - 2.03x slower +156.22 ns
String.upcase/1                      2.77 M - 2.39x slower +209.69 ns

Memory usage statistics:

Name                             Memory usage
Helper.is_method?                        40 B
Helper.is_method_2?                      64 B - 1.60x memory usage +24 B
String.upcase(source, :ascii)           176 B - 4.40x memory usage +136 B
String.upcase/1                         288 B - 7.20x memory usage +248 B

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you bench if a regex case-insensitive check performs much worse than this? Basically check for method =~ ~r/\Aconnect\z/i. Usually regexes are pretty fast and it would avoid the 15 additional LoCs 🙃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah it looks like the regex ends up being a fair bit slower than the String.upcase/2s 😬

Benchee script
Mix.install([:benchee])
Code.ensure_loaded(String)

defmodule Helper do
  @spec is_method?(proposed :: binary(), method :: charlist()) :: boolean()
  def is_method?(<<>>, []), do: true

  def is_method?(<<char, rest_bin::binary>>, [char | rest_list]),
    do: is_method?(rest_bin, rest_list)

  def is_method?(<<lower_char, rest_bin::binary>>, [char | rest_list])
      when lower_char >= ?a and lower_char <= ?z and lower_char - 32 == char do
    is_method?(rest_bin, rest_list)
  end

  def is_method?(_proposed, _method), do: false

  @spec is_method_2?(proposed :: binary(), method :: binary()) :: boolean()
  def is_method_2?(method, method), do: true

  def is_method_2?(<<char, rest_proposed::binary>>, <<char, rest_method::binary>>),
    do: is_method_2?(rest_proposed, rest_method)

  def is_method_2?(<<lower_char, rest_proposed::binary>>, <<char, rest_method::binary>>)
      when lower_char >= ?a and lower_char <= ?z and lower_char - 32 == char do
    is_method_2?(rest_proposed, rest_method)
  end

  def is_method_2?(_proposed, _method), do: false
end

source = "POST"

Benchee.run(
  %{
    "String.upcase/1" => fn ->
      String.upcase(source) == "CONNECT"
    end,
    "String.upcase(source, :ascii)" => fn ->
      String.upcase(source, :ascii) == "CONNECT"
    end,
    "Helper.is_method?" => fn ->
      Helper.is_method?(source, ~c"CONNECT")
    end,
    "Helper.is_method_2?" => fn ->
      Helper.is_method_2?(source, ~c"CONNECT")
    end,
    "Regex case-insensitive match" => fn ->
      source =~ ~r/\Aconnect\z/i
    end
  },
  warmup: 3,
  time: 15,
  memory_time: 15
)
Results
source = "POST"
---
Name                                    ips        average  deviation         median         99th %
Helper.is_method?                    7.00 M      142.93 ns ±39330.15%          91 ns         231 ns
Helper.is_method_2?                  4.98 M      200.84 ns ±34584.97%         110 ns         401 ns
String.upcase(source, :ascii)        3.90 M      256.67 ns ±21923.01%         181 ns         491 ns
String.upcase/1                      3.56 M      280.76 ns ±18300.86%         221 ns         501 ns
Regex case-insensitive match         1.17 M      852.35 ns  ±7533.60%         761 ns         962 ns

Comparison: 
Helper.is_method?                    7.00 M
Helper.is_method_2?                  4.98 M - 1.41x slower +57.91 ns
String.upcase(source, :ascii)        3.90 M - 1.80x slower +113.74 ns
String.upcase/1                      3.56 M - 1.96x slower +137.83 ns
Regex case-insensitive match         1.17 M - 5.96x slower +709.42 ns

Memory usage statistics:

Name                             Memory usage
Helper.is_method?                        40 B
Helper.is_method_2?                      64 B - 1.60x memory usage +24 B
String.upcase(source, :ascii)           128 B - 3.20x memory usage +88 B
String.upcase/1                         192 B - 4.80x memory usage +152 B
Regex case-insensitive match             56 B - 1.40x memory usage +16 B



source = "connect"
---
Name                                    ips        average  deviation         median         99th %
Helper.is_method?                    6.06 M      165.14 ns ±34145.79%         130 ns         251 ns
Helper.is_method_2?                  4.96 M      201.66 ns ±33980.79%         110 ns         401 ns
String.upcase(source, :ascii)        3.23 M      309.30 ns ±20909.48%         221 ns         511 ns
String.upcase/1                      2.69 M      372.38 ns ±17259.43%         241 ns        1723 ns
Regex case-insensitive match         1.12 M      888.92 ns  ±7217.10%         792 ns        1002 ns

Comparison: 
Helper.is_method?                    6.06 M
Helper.is_method_2?                  4.96 M - 1.22x slower +36.52 ns
String.upcase(source, :ascii)        3.23 M - 1.87x slower +144.16 ns
String.upcase/1                      2.69 M - 2.25x slower +207.24 ns
Regex case-insensitive match         1.12 M - 5.38x slower +723.78 ns

Memory usage statistics:

Name                             Memory usage
Helper.is_method?                        40 B
Helper.is_method_2?                      64 B - 1.60x memory usage +24 B
String.upcase(source, :ascii)           176 B - 4.40x memory usage +136 B
String.upcase/1                         288 B - 7.20x memory usage +248 B
Regex case-insensitive match             56 B - 1.40x memory usage +16 B



source = "CONNECT"
---
Name                                    ips        average  deviation         median         99th %
Helper.is_method?                    6.37 M      157.06 ns ±35458.04%         120 ns         250 ns
Helper.is_method_2?                  4.97 M      201.05 ns ±34113.80%         110 ns         401 ns
String.upcase(source, :ascii)        3.27 M      306.06 ns ±21259.05%         221 ns         511 ns
String.upcase/1                      2.73 M      366.85 ns ±17525.86%         240 ns        1733 ns
Regex case-insensitive match         1.13 M      887.71 ns  ±6217.62%         802 ns        1022 ns

Comparison: 
Helper.is_method?                    6.37 M
Helper.is_method_2?                  4.97 M - 1.28x slower +43.99 ns
String.upcase(source, :ascii)        3.27 M - 1.95x slower +149.01 ns
String.upcase/1                      2.73 M - 2.34x slower +209.79 ns
Regex case-insensitive match         1.13 M - 5.65x slower +730.66 ns

Memory usage statistics:

Name                             Memory usage
Helper.is_method?                        40 B
Helper.is_method_2?                      64 B - 1.60x memory usage +24 B
String.upcase(source, :ascii)           176 B - 4.40x memory usage +136 B
String.upcase/1                         288 B - 7.20x memory usage +248 B
Regex case-insensitive match             56 B - 1.40x memory usage +16 B

@the-mikedavis the-mikedavis force-pushed the optimize-http/2-request-creation branch from 7c78455 to 5f66021 Compare February 3, 2024 20:50
@the-mikedavis the-mikedavis force-pushed the optimize-http/2-request-creation branch 2 times, most recently from 06f620f to 7bb2e5b Compare February 3, 2024 21:38
@coveralls
Copy link

Pull Request Test Coverage Report for Build 7bb2e5bf376de14cf298d262e723658c7c60747f-PR-423

  • -1 of 8 (87.5%) changed or added relevant lines in 1 file are covered.
  • No unchanged relevant lines lost coverage.
  • Overall first build on optimize-http/2-request-creation at 87.544%

Changes Missing Coverage Covered Lines Changed/Added Lines %
lib/mint/http2.ex 7 8 87.5%
Totals Coverage Status
Change from base Build 321c8303ed626452c8b010b605950ec62e23e7e2: 87.5%
Covered Lines: 1251
Relevant Lines: 1429

💛 - Coveralls

lib/mint/http2.ex Outdated Show resolved Hide resolved
| headers
]
end
end

@spec is_method?(proposed :: binary(), method :: charlist()) :: boolean()
defp is_method?(<<>>, []), do: true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you bench if a regex case-insensitive check performs much worse than this? Basically check for method =~ ~r/\Aconnect\z/i. Usually regexes are pretty fast and it would avoid the 15 additional LoCs 🙃

When constructing the authority pseudo header we check if the conn's
port is the default for the conn's scheme using `URI.default_port/1`.
That corresponds to an ETS lookup into the `:elixir_config` table. It's
relatively fast to read that information but the conn's port and scheme
are static for the life of the conn, so we should determine this
information once while initiating the conn (`Mint.HTTP2.initiate/5`).
This change saves a small amount of time per request and becomes more
valuable as the conn is re-used for more requests.
`String.upcase/1` by default iterates over the graphemes in the
input string which can be relatively slow. Since we're only interested
in case-insensitive equality, we can write a small custom equality
checking function that can avoid creating new terms and can return
early.

Using a charlist is slightly faster than passing `"CONNECT"` as a
binary.
@the-mikedavis the-mikedavis force-pushed the optimize-http/2-request-creation branch from 7bb2e5b to f8ece36 Compare February 5, 2024 14:47
@whatyouhide
Copy link
Contributor

Ah this is fantastic, thanks for the benchmarks @the-mikedavis 💟

@whatyouhide whatyouhide merged commit 1dd3601 into elixir-mint:main Feb 5, 2024
4 checks passed
@the-mikedavis the-mikedavis deleted the optimize-http/2-request-creation branch February 5, 2024 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants