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

benchmark async vs. sync http providers #2002

Merged
merged 1 commit into from
May 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,14 @@ jobs:
environment:
TOXENV: py39-wheel-cli

benchmark:
<<: *geth_steps
docker:
- image: circleci/python:3.9
environment:
TOXENV: benchmark
GETH_VERSION: v1.10.1

workflows:
version: 2.1
test:
Expand All @@ -571,6 +579,7 @@ workflows:
- py39-core
- lint
- docs
- benchmark
- py36-ens
- py36-ethpm
- py36-integration-goethereum-ipc
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ test:
test-all:
tox

benchmark:
tox -e benchmark

build-docs:
sphinx-apidoc -o docs/ . setup.py "web3/utils/*" "*conftest*" "tests" "ethpm"
$(MAKE) -C docs clean
Expand Down
1 change: 1 addition & 0 deletions newsfragments/2002.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add basic provider benchmarking infrastructure
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"click>=5.1",
"configparser==3.5.0",
"contextlib2>=0.5.4",
"py-geth>=2.4.0,<3",
"py-geth>=3.0.0,<4",
"py-solc>=0.4.0",
"pytest>=4.4.0,<5.0.0",
"sphinx>=3.0,<4",
Expand Down
14 changes: 11 additions & 3 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ envlist=
py{36,37,38,39}-integration-{goethereum,ethtester,parity}
lint
docs
benchmark
py{36,37,38,39}-wheel-cli

[isort]
Expand Down Expand Up @@ -63,9 +64,16 @@ basepython =
basepython=python
extras=linter
commands=
flake8 {toxinidir}/web3 {toxinidir}/ens {toxinidir}/ethpm {toxinidir}/tests --exclude {toxinidir}/ethpm/ethpm-spec
isort --recursive --check-only --diff {toxinidir}/web3/ {toxinidir}/ens/ {toxinidir}/ethpm/ {toxinidir}/tests/
mypy -p web3 -p ethpm -p ens --config-file {toxinidir}/mypy.ini
flake8 {toxinidir}/web3 {toxinidir}/ens {toxinidir}/ethpm {toxinidir}/tests --exclude {toxinidir}/ethpm/ethpm-spec
isort --recursive --check-only --diff {toxinidir}/web3/ {toxinidir}/ens/ {toxinidir}/ethpm/ {toxinidir}/tests/
mypy -p web3 -p ethpm -p ens --config-file {toxinidir}/mypy.ini

[testenv:benchmark]
basepython=python
commands=
python {toxinidir}/web3/tools/benchmark/main.py --num-calls 5
python {toxinidir}/web3/tools/benchmark/main.py --num-calls 50
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just curious - what's the reasoning behind testing 5, 50, and 100 calls here and not just checking one?

Copy link
Member Author

Choose a reason for hiding this comment

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

Once the suite is more mature, we should have vectors for benchmarking (the average of) 1 call vs. 1 call; this would be useful for comparing the HTTPProvider vs. IPCProvider, for example. I think making many calls is the best way to showcase the difference between AsyncHTTPProvider vs. HTTPProvider performance.

python {toxinidir}/web3/tools/benchmark/main.py --num-calls 100

[common-wheel-cli]
deps=wheel
Expand Down
Empty file.
140 changes: 140 additions & 0 deletions web3/tools/benchmark/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import argparse
import asyncio
from collections import (
defaultdict,
)
import logging
import sys
import timeit
from typing import (
Any,
Callable,
Dict,
Union,
)

from web3 import (
AsyncHTTPProvider,
HTTPProvider,
Web3,
)
from web3.eth import (
AsyncEth,
)
from web3.tools.benchmark.node import (
GethBenchmarkFixture,
)
from web3.tools.benchmark.reporting import (
print_entry,
print_footer,
print_header,
)
from web3.tools.benchmark.utils import (
wait_for_aiohttp,
wait_for_http,
)

parser = argparse.ArgumentParser()
parser.add_argument(
"--num-calls", type=int, default=10, help="The number of RPC calls to make",
)

# TODO - layers to test:
# contract.functions.method(...).call()
# w3.eth.call(...)
# HTTPProvider.make_request(...)


def build_web3_http(endpoint_uri: str) -> Web3:
wait_for_http(endpoint_uri)
_web3 = Web3(HTTPProvider(endpoint_uri), middlewares=[])
return _web3


async def build_async_w3_http(endpoint_uri: str) -> Web3:
await wait_for_aiohttp(endpoint_uri)
_web3 = Web3(
AsyncHTTPProvider(endpoint_uri), # type: ignore
middlewares=[],
modules={"async_eth": (AsyncEth,)},
)
return _web3


def sync_benchmark(func: Callable[..., Any], n: int) -> Union[float, str]:
try:
starttime = timeit.default_timer()
for _ in range(n):
func()
endtime = timeit.default_timer()
execution_time = endtime - starttime
return execution_time
except Exception:
return "N/A"


async def async_benchmark(func: Callable[..., Any], n: int) -> Union[float, str]:
try:
starttime = timeit.default_timer()
for result in asyncio.as_completed([func() for _ in range(n)]):
await result
execution_time = timeit.default_timer() - starttime
return execution_time
except Exception:
return "N/A"


def main(logger: logging.Logger, num_calls: int) -> None:
fixture = GethBenchmarkFixture()
for built_fixture in fixture.build():
for process in built_fixture:
w3_http = build_web3_http(fixture.endpoint_uri)
loop = asyncio.get_event_loop()
async_w3_http = loop.run_until_complete(build_async_w3_http(fixture.endpoint_uri))

methods = [
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe worth pulling this out into a constant?

Copy link
Member Author

Choose a reason for hiding this comment

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

There's probably a good way to clean this up a bit, but the methods rely on the providers built within this context. Will revisit next iteration.

{
"name": "eth_gasPrice",
"params": {},
"exec": lambda: w3_http.eth.gas_price,
"async_exec": lambda: async_w3_http.async_eth.gas_price,
},
{
"name": "eth_blockNumber",
"params": {},
"exec": lambda: w3_http.eth.block_number,
"async_exec": lambda: (_ for _ in ()).throw(Exception("not implemented yet")),
},
{
"name": "eth_getBlock",
"params": {},
"exec": lambda: w3_http.eth.get_block("1"),
"async_exec": lambda: (_ for _ in ()).throw(Exception("not implemented yet")),
},
]

def benchmark(method: Dict[str, Any]) -> None:
outcomes: Dict[str, Union[str, float]] = defaultdict(lambda: "N/A")
outcomes["name"] = method["name"]
outcomes["HTTPProvider"] = sync_benchmark(method["exec"], num_calls,)
outcomes["AsyncHTTPProvider"] = loop.run_until_complete(
async_benchmark(method["async_exec"], num_calls)
)
print_entry(logger, outcomes)

print_header(logger, num_calls)

for method in methods:
benchmark(method)

print_footer(logger)


if __name__ == "__main__":
args = parser.parse_args()

logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler(sys.stdout))

main(logger, args.num_calls)
115 changes: 115 additions & 0 deletions web3/tools/benchmark/node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import os
import socket
from subprocess import (
PIPE,
Popen,
check_output,
)
from tempfile import (
TemporaryDirectory,
)
from typing import (
Any,
Generator,
Sequence,
)
import zipfile

from geth.install import (
get_executable_path,
install_geth,
)

from web3.tools.benchmark.utils import (
kill_proc_gracefully,
)

GETH_FIXTURE_ZIP = "geth-1.10.1-fixture.zip"


class GethBenchmarkFixture:
def __init__(self) -> None:
self.rpc_port = self._rpc_port()
self.endpoint_uri = self._endpoint_uri()
self.geth_binary = self._geth_binary()

def build(self) -> Generator[Any, None, None]:
with TemporaryDirectory() as base_dir:
zipfile_path = os.path.abspath(
os.path.join(
os.path.dirname(__file__),
"../../../tests/integration/",
GETH_FIXTURE_ZIP,
)
)
tmp_datadir = os.path.join(str(base_dir), "datadir")
with zipfile.ZipFile(zipfile_path, "r") as zip_ref:
zip_ref.extractall(tmp_datadir)
self.datadir = tmp_datadir

genesis_file = os.path.join(self.datadir, "genesis.json")

yield self._geth_process(self.datadir, genesis_file, self.rpc_port)

def _rpc_port(self) -> str:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return str(port)

def _endpoint_uri(self) -> str:
return "http://localhost:{0}".format(self.rpc_port)

def _geth_binary(self) -> str:
if "GETH_BINARY" in os.environ:
return os.environ["GETH_BINARY"]
elif "GETH_VERSION" in os.environ:
geth_version = os.environ["GETH_VERSION"]
_geth_binary = get_executable_path(geth_version)
if not os.path.exists(_geth_binary):
install_geth(geth_version)
assert os.path.exists(_geth_binary)
return _geth_binary
else:
return "geth"

def _geth_command_arguments(self, datadir: str) -> Sequence[str]:
return (
self.geth_binary,
"--datadir",
str(datadir),
"--nodiscover",
"--fakepow",
"--http",
"--http.port",
self.rpc_port,
"--http.api",
"admin,eth,net,web3,personal,miner",
"--ipcdisable",
"--allow-insecure-unlock",
)

def _geth_process(
self, datadir: str, genesis_file: str, rpc_port: str
) -> Generator[Any, None, None]:
init_datadir_command = (
self.geth_binary,
"--datadir",
str(datadir),
"init",
str(genesis_file),
)
check_output(
init_datadir_command, stdin=PIPE, stderr=PIPE,
)
proc = Popen(
self._geth_command_arguments(datadir),
stdin=PIPE,
stdout=PIPE,
stderr=PIPE,
)
try:
yield proc
finally:
kill_proc_gracefully(proc)
36 changes: 36 additions & 0 deletions web3/tools/benchmark/reporting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from logging import (
Logger,
)
from typing import (
Any,
Dict,
)


def print_header(logger: Logger, num_calls: int) -> None:
logger.info(
"|{:^26}|{:^20}|{:^20}|{:^20}|{:^20}|".format(
f"Method ({num_calls} calls)",
"HTTPProvider",
"AsyncHTTProvider",
"IPCProvider",
"WebsocketProvider",
)
)
logger.info("-" * 112)


def print_entry(logger: Logger, method_benchmarks: Dict[str, Any],) -> None:
logger.info(
"|{:^26}|{:^20.10}|{:^20.10}|{:^20.10}|{:^20.10}|".format(
method_benchmarks["name"],
method_benchmarks["HTTPProvider"],
method_benchmarks["AsyncHTTPProvider"],
method_benchmarks["IPCProvider"],
method_benchmarks["WebsocketProvider"],
)
)


def print_footer(logger: Logger) -> None:
logger.info("-" * 112)
Loading