Repository: Neoteroi/BlackSheep
Pull request #558 (2025-05-10 23:35:20)

Add framework to measure code base performance ✨ 💎 
- Add framework to measure code base performance. This is to refactor more comfortably Cython code and core features, keeping an eye on performance.
- Download the `benchmark-reports` artifact from the GitHub Workflow to see it in action. https://github.com/Neoteroi/BlackSheep/actions/runs/14950087869

> [!TIP]
>
> The `benchmark-reports` artifacts include Excel files with tables and charts.
>
>  [![Build](https://github.com/Neoteroi/BlackSheep/workflows/Benchmark/badge.svg)](https://github.com/Neoteroi/BlackSheep/actions/workflows/perf.yml)


Patch:
diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml
new file mode 100644
index 0000000..c600bfe
--- /dev/null
+++ b/.github/workflows/perf.yml
@@ -0,0 +1,110 @@
+####################################################################################
+# Runs benchmarks for BlackSheep source code for various versions of Python
+# and Operating System and publishes the results.
+# See the perf folder for more information.
+####################################################################################
+name: Benchmark
+
+on:
+  push:
+    paths:
+      - 'perf/**'
+  workflow_dispatch:
+
+jobs:
+  perf-tests:
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.11", "3.12", "3.13"]
+        os: [ubuntu-latest, macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 9
+          submodules: false
+
+      - name: Use Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          pip install -r requirements.txt
+          pip install flake8
+
+      - name: Compile Cython extensions
+        run: |
+          cython blacksheep/url.pyx
+          cython blacksheep/exceptions.pyx
+          cython blacksheep/headers.pyx
+          cython blacksheep/cookies.pyx
+          cython blacksheep/contents.pyx
+          cython blacksheep/messages.pyx
+          cython blacksheep/scribe.pyx
+          cython blacksheep/baseapp.pyx
+          python setup.py build_ext --inplace
+
+      - name: Install dependencies for benchmark
+        run: |
+          cd perf
+          pip install -r req.txt
+
+      - name: Run benchmark
+        shell: bash
+        run: |
+          export PYTHONPATH="."
+          python perf/main.py --times 5
+
+      - name: Upload results
+        uses: actions/upload-artifact@v4
+        with:
+          name: benchmark-results-${{ matrix.os }}-${{ matrix.python-version }}
+          path: benchmark_results
+
+  genreport:
+    runs-on: ubuntu-latest
+    needs: [perf-tests]
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 9
+          submodules: false
+
+      - name: Download a distribution artifact
+        uses: actions/download-artifact@v4
+        with:
+          pattern: benchmark-results-*
+          merge-multiple: true
+          path: benchmark_results
+
+      - name: Use Python 3.13
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.13'
+
+      - name: Install dependencies
+        run: |
+          cd perf
+          pip install -r req.txt
+
+      - name: Generate report
+        shell: bash
+        run: |
+          ls -R benchmark_results
+          chmod -R 755 benchmark_results
+
+          export PYTHONPATH="."
+          python perf/genreport.py
+          python perf/genreport.py --output windows-results.xlsx --platform Windows
+          python perf/genreport.py --output linux-results.xlsx --platform Linux
+          python perf/genreport.py --output macos-results.xlsx --platform macOS
+
+      - name: Upload reports
+        uses: actions/upload-artifact@v4
+        with:
+          name: benchmark-reports
+          path: "**/*.xlsx"  # Upload all .xlsx files
diff --git a/.gitignore b/.gitignore
index bfd4101..07cfe13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -128,3 +128,6 @@ nice-cat.jpg
 venv*
 
 .local/*
+benchmark_results/
+*.xlsx
+.~lock*
diff --git a/perf/README.md b/perf/README.md
new file mode 100644
index 0000000..32d0ae2
--- /dev/null
+++ b/perf/README.md
@@ -0,0 +1,87 @@
+# Benchmark
+
+This folder contains scripts to benchmark the performance of the library. The
+purpose of these benchmarks is to measure how changes in code affect
+performance, across Git commits, Python versions, and operating system.
+
+Benchmarks measure execution time and memory utilization.
+
+> [!TIP]
+>
+> Download the results from the GitHub Workflow.
+> The `benchmark-reports` artifacts include Excel files with tables and charts.
+>
+>  [![Build](https://github.com/Neoteroi/BlackSheep/workflows/Benchmark/badge.svg)](https://github.com/Neoteroi/BlackSheep/actions/workflows/perf.yml)
+
+The code can both collect information and compare it depending on the Git
+commit SHA.
+
+```bash
+pip install -r req.txt
+```
+
+From the root folder:
+
+```bash
+# Run the benchmark suite
+export PYTHONPATH="."
+
+python perf/main.py
+
+# To run more than onces:
+python perf/main.py --times 3
+
+# Generate XLSX report
+python perf/genreport.py
+```
+
+Run to generate results from different points in history:
+
+```bash
+python perf/historyrun.py --commits 82ed065 1237b1e
+```
+
+## Code organization
+
+Benchmarks are organized in such way that each file can be run interactively using
+**iPython**, but are also automatically imported by `main.py` following the convention
+that benchmark functions have names starting with `benchmark_`.
+
+To run a single benchmark using **iPython**, or [`cProfile`](https://docs.python.org/3.13/library/profile.html#profile-cli):
+
+```bash
+export PYTHONPATH="."
+
+ipython perf/benchmarks/writeresponse.py timeit
+
+python -m cProfile -s tottime perf/benchmarks/writeresponse.py | head -n 50
+```
+
+## Debugging with Visual Studio Code
+
+To debug specific files with VS Code, use a `.vscode\launch.json` file like:
+
+```json
+{
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Python Debugger: Current File",
+            "type": "debugpy",
+            "request": "launch",
+            "program": "${file}",
+            "console": "integratedTerminal",
+            "env": {
+                "PYTHONPATH": "${workspaceFolder}"
+            }
+        }
+    ]
+}
+```
+
+## When modifying benchmark code
+
+```bash
+export PYTHONPATH="."
+rm -rf benchmark_results && python perf/main.py && python perf/genreport.py
+```
diff --git a/perf/benchmarks/__init__.py b/perf/benchmarks/__init__.py
new file mode 100644
index 0000000..36775f0
--- /dev/null
+++ b/perf/benchmarks/__init__.py
@@ -0,0 +1,93 @@
+import gc
+import time
+from contextlib import contextmanager
+from dataclasses import dataclass
+from typing import TypedDict
+
+
+class BenchmarkResult(TypedDict):
+    total_time: float
+    avg_time: float
+    iterations: int
+
+
+@dataclass
+class TimerResult:
+    elapsed_time: float
+
+
+@contextmanager
+def timer():
+    result = TimerResult(-1)
+    start_time = time.perf_counter()  # Use perf_counter for high-resolution timing
+    yield result
+    end_time = time.perf_counter()
+    result.elapsed_time = end_time - start_time
+
+
+async def async_benchmark(func, iterations: int) -> BenchmarkResult:
+    # warmup
+    warmup_iterations = max(1, min(100, iterations // 10))
+    for _ in range(warmup_iterations):
+        await func()
+
+    # Collect garbage to ensure fair comparison
+    gc.collect()
+
+    # actual timing
+    with timer() as result:
+        for _ in range(iterations):
+            await func()
+
+    return {
+        "total_time": result.elapsed_time,
+        "avg_time": result.elapsed_time / iterations,
+        "iterations": iterations,
+    }
+
+
+def sync_benchmark(func, iterations: int) -> BenchmarkResult:
+    # warmup
+    warmup_iterations = max(1, min(100, iterations // 10))
+    for _ in range(warmup_iterations):
+        func()
+
+    # Collect garbage to ensure fair comparison
+    gc.collect()
+
+    # actual timing
+    with timer() as result:
+        for _ in range(iterations):
+            func()
+
+    return {
+        "total_time": result.elapsed_time,
+        "avg_time": result.elapsed_time / iterations,
+        "iterations": iterations,
+    }
+
+
+def main_run(func):
+    """
+    Run the benchmark function and print the results.
+
+    To use with iPython:
+    PYTHONPATH="." ipython perf/benchmarks/filename.py timeit
+
+    To use with asyncio:
+    PYTHONPATH="." ipython perf/benchmarks/filename.py
+    """
+    import asyncio
+    import sys
+
+    if len(sys.argv) > 1 and sys.argv[1] == "timeit":
+        from IPython import get_ipython
+
+        ipython = get_ipython()
+        if ipython:
+            ipython.run_line_magic("timeit", f"asyncio.run({func.__name__}(1))")
+        else:
+            print("ERROR: Use iPython to run the benchmark with timeit.")
+            sys.exit(1)
+    else:
+        asyncio.run(func())
diff --git a/perf/benchmarks/app.py b/perf/benchmarks/app.py
new file mode 100644
index 0000000..38f2d93
--- /dev/null
+++ b/perf/benchmarks/app.py
@@ -0,0 +1,161 @@
+"""
+Benchmarks testing end-2-end handling of HTTP requests, mocking ASGI scopes.
+"""
+
+from functools import partial
+from pathlib import Path
+
+from blacksheep import Application, Response, Router
+from blacksheep.contents import TextContent
+from blacksheep.server.controllers import Controller
+from blacksheep.server.responses import text
+from blacksheep.testing.helpers import get_example_scope
+from blacksheep.testing.messages import MockReceive, MockSend
+from perf.benchmarks import async_benchmark, main_run
+
+ITERATIONS = 10000
+LOREM_IPSUM = (Path(__file__).parent / "res" / "lorem.txt").read_text(encoding="utf-8")
+REQUEST_HEADERS = [
+    (b"Connection", b"keep-alive"),
+    (b"Cache-Control", b"max-age=0"),
+    (
+        b"sec-ch-ua",
+        b'"Chromium";v="112", "Google Chrome";v="112", "Not:A-Brand";v="99"',
+    ),
+    (b"sec-ch-ua-mobile", b"?0"),
+    (b"sec-ch-ua-platform", b'"Windows"'),
+    (b"Upgrade-Insecure-Requests", b"1"),
+    (
+        b"User-Agent",
+        b"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36",
+    ),
+    (
+        b"Accept",
+        b"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+    ),
+    (b"Sec-Fetch-Site", b"none"),
+    (b"Sec-Fetch-Mode", b"navigate"),
+    (b"Sec-Fetch-User", b"?1"),
+    (b"Sec-Fetch-Dest", b"document"),
+    (b"Accept-Encoding", b"gzip, deflate, br"),
+    (b"Accept-Language", b"en-US,en;q=0.9"),
+]
+RESPONSE_HEADERS = [
+    (b"Content-Type", b"text/html; charset=utf-8"),
+    (b"Content-Length", b"123"),
+    (b"Connection", b"keep-alive"),
+    (b"Cache-Control", b"no-cache, no-store, must-revalidate"),
+    (b"Pragma", b"no-cache"),
+    (b"Expires", b"0"),
+    (b"X-Frame-Options", b"DENY"),
+    (b"X-Content-Type-Options", b"nosniff"),
+    (b"X-XSS-Protection", b"1; mode=block"),
+    (b"Strict-Transport-Security", b"max-age=31536000; includeSubDomains"),
+    (b"Server", b"BlackSheep/1.0"),
+]
+
+
+async def test_app_handle_small_response(application: Application):
+    scope = get_example_scope(
+        "GET", "/test", extra_headers=REQUEST_HEADERS, query=b"q=1&x=2"
+    )
+    mock_send = MockSend()
+    await application(scope, MockReceive(), mock_send)
+    assert mock_send.messages[1]["body"] == b"Hello, World!"
+
+
+async def test_app_handle_small_response_with_qs(application: Application):
+    scope = get_example_scope(
+        "GET", "/test", extra_headers=REQUEST_HEADERS, query=b"name=World"
+    )
+    mock_send = MockSend()
+    await application(scope, MockReceive(), mock_send)
+    assert mock_send.messages[1]["body"] == b"Hello, World"
+
+
+async def test_app_handle_text_response(application: Application):
+    scope = get_example_scope("GET", "/test", extra_headers=REQUEST_HEADERS)
+    mock_send = MockSend()
+    await application(scope, MockReceive(), mock_send)
+    assert len(mock_send.messages) == 2
+
+
+async def benchmark_app_handle_small_response(iterations=ITERATIONS):
+    application = Application(router=Router())
+    application.router.add_get("/test", lambda _: "Hello, World!")
+    await application.start()
+    return await async_benchmark(
+        partial(test_app_handle_small_response, application), iterations
+    )
+
+
+async def benchmark_app_handle_small_response_with_qs(iterations=ITERATIONS):
+    application = Application(router=Router())
+
+    @application.router.get("/test")
+    def handle(name: str) -> Response:
+        return text(f"Hello, {name}")
+
+    await application.start()
+    return await async_benchmark(
+        partial(test_app_handle_small_response_with_qs, application), iterations
+    )
+
+
+async def benchmark_app_handle_text_response(iterations=ITERATIONS):
+    application = Application(router=Router())
+
+    @application.router.get("/test")
+    async def test_handler() -> Response:
+        return Response(200, RESPONSE_HEADERS).with_content(TextContent(LOREM_IPSUM))
+
+    await application.start()
+    return await async_benchmark(
+        partial(test_app_handle_text_response, application), iterations
+    )
+
+
+async def benchmark_app_handle_small_response_controller(iterations=ITERATIONS):
+    application = Application(router=Router())
+
+    class TestController(Controller):
+        @application.router.controllers_routes.get("/test")
+        def hello_world(self) -> Response:
+            return self.text("Hello, World!")
+
+    await application.start()
+    return await async_benchmark(
+        partial(test_app_handle_small_response, application), iterations
+    )
+
+
+async def benchmark_app_handle_small_response_controller_with_qs(iterations=ITERATIONS):
+    application = Application(router=Router())
+
+    class TestController(Controller):
+        @application.router.controllers_routes.get("/test")
+        def hello_world(self, name: str) -> Response:
+            return self.text(f"Hello, {name}")
+
+    await application.start()
+    return await async_benchmark(
+        partial(test_app_handle_small_response_with_qs, application), iterations
+    )
+
+
+async def benchmark_app_handle_text_response_controller(iterations=ITERATIONS):
+    application = Application(router=Router())
+
+    class TestController(Controller):
+        @application.router.controllers_routes.get("/test")
+        def hello_world(self) -> Response:
+            return self.text(LOREM_IPSUM)
+
+    await application.start()
+    return await async_benchmark(
+        partial(test_app_handle_text_response, application), iterations
+    )
+
+
+if __name__ == "__main__":
+    main_run(benchmark_app_handle_small_response_with_qs)
diff --git a/perf/benchmarks/res/lorem.txt b/perf/benchmarks/res/lorem.txt
new file mode 100644
index 0000000..09aa066
--- /dev/null
+++ b/perf/benchmarks/res/lorem.txt
@@ -0,0 +1,149 @@
+Lorem ipsum (/ˌlɔː.rəm ˈɪp.səm/ LOR-əm IP-səm) is a dummy or placeholder text commonly
+used in graphic design, publishing, and web development. Its purpose is to permit a page
+layout to be designed, independently of the copy that will subsequently populate it, or
+to demonstrate various fonts of a typeface without meaningful text that could be
+distracting.
+
+Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a
+1st-century BC text by the Roman statesman and philosopher Cicero, with words altered,
+added, and removed to make it nonsensical and improper Latin. The first two words are
+the truncation of dolorem ipsum ("pain itself").
+
+Versions of the Lorem ipsum text have been used in typesetting since the 1960s, when
+advertisements for Letraset transfer sheets popularized it.[1] Lorem ipsum was
+introduced to the digital world in the mid-1980s, when Aldus employed it in graphic and
+word-processing templates for its desktop publishing program PageMaker. Other popular
+word processors, including Pages and Microsoft Word, have since adopted Lorem ipsum,[2]
+as have many LaTeX packages,[3][4][5] web content managers such as Joomla! and
+WordPress, and CSS libraries such as Semantic UI. Example text
+
+A common form of Lorem ipsum reads:
+
+    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
+    incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
+    exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute
+    irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
+    pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
+    deserunt mollit anim id est laborum.
+
+Source text
+
+The Lorem ipsum text is derived from sections 1.10.32 and 1.10.33 of Cicero's De finibus
+bonorum et malorum.[6][7] The physical source may have been the 1914 Loeb Classical
+Library edition of De finibus, where the Latin text, presented on the left-hand (even)
+pages, breaks off on page 34 with "Neque porro quisquam est qui do-" and continues on
+page 36 with "lorem ipsum ...," suggesting that the galley type of that page was mixed
+up to make the dummy text seen today.[1]
+
+The discovery of the text's origin is attributed to Richard McClintock, a Latin scholar
+at Hampden–Sydney College. McClintock connected Lorem ipsum to Cicero's writing sometime
+before 1982 while searching for instances of the Latin word consectetur, which was
+rarely used in classical literature.[2] McClintock first published his discovery in a
+1994 letter to the Before & After magazine editor,[8] contesting the editor's earlier
+claim that Lorem ipsum had no meaning.[2]
+
+The relevant section of Cicero as printed in the source is reproduced below with
+fragments used in Lorem ipsum highlighted. Letters in brackets were added to Lorem ipsum
+and were not present in the source text:
+
+    [32] Sed ut perspiciatis, unde omnis iste natus error sit voluptatem accusantium
+    doloremque laudantium, totam rem aperiam eaque ipsa, quae ab illo inventore
+    veritatis et quasi architecto beatae vitae dicta sunt, explicabo. Nemo enim ipsam
+    voluptatem, quia voluptas sit, aspernatur aut odit aut fugit, sed quia consequuntur
+    magni dolores eos, qui ratione voluptatem sequi nesciunt, neque porro quisquam est,
+    qui dolorem ipsum, quia dolor sit amet consectetur adipisci[ng] velit, sed quia non
+    numquam [do] eius modi tempora inci[di]dunt, ut labore et dolore magnam aliquam
+    quaerat voluptatem. Ut enim ad minima veniam, quis nostrum[d] exercitationem ullam
+    corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? [D]Quis
+    autem vel eum i[r]ure reprehenderit, qui in ea voluptate velit esse, quam nihil
+    molestiae consequatur, vel illum, qui dolorem eum fugiat, quo voluptas nulla
+    pariatur?
+
+    [33] At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis
+    praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias
+    excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui
+    officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem
+    reru[d]um facilis est e[r]t expedita distinctio. Nam libero tempore, cum soluta
+    nobis est eligendi optio, cumque nihil impedit, quo minus id, quod maxime placeat
+    facere possimus, omnis voluptas assumenda est, omnis dolor repellend[a]us.
+    Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe
+    eveniet, ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum
+    rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias
+    consequatur aut perferendis doloribus asperiores repellat.
+
+What follows is H. Rackham's translation, as printed in the 1914 Loeb edition, with
+words at least partially represented in Lorem ipsum highlighted:[7]
+
+    [32] But I must explain to you how all this mistaken idea of reprobating pleasure
+    and extolling pain arose. To do so, I will give you a complete account of the system
+    and expound the teachings of the great explorer of the truth, the master-builder of
+    human happiness. No one rejects, dislikes or avoids pleasure itself, because it is
+    pleasure, but because those who do not know how to pursue pleasure rationally
+    encounter extremely painful consequences. Nor again is there anyone who loves or
+    pursues or desires to obtain pain of itself, because it is pain, but occasionally
+    circumstances occur in which toil and pain can procure him some great pleasure. To
+    take a trivial example, which of us ever undertakes laborious physical exercise,
+    except to obtain some advantage? But who has any right to find fault with a man who
+    chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a
+    pain that produces no resultant pleasure?
+
+    [33] On the other hand, we denounce with righteous indignation and dislike men who
+    are so beguiled and demoralized by the charms of pleasure of the moment, so blinded
+    by desire, that they cannot foresee the pain and trouble that are bound to ensue;
+    and equal blame belongs to those who fail in their duty through weakness of will,
+    which is the same as saying through shrinking from toil and pain. These cases are
+    perfectly simple and easy to distinguish. In a free hour, when our power of choice
+    is untrammeled and nothing prevents us from being what we like best, every pleasure
+    is welcomed and every pain avoided. But in certain circumstances and owing to the
+    claims of duty or the obligations of business it will frequently occur that
+    pleasures must be repudiated and annoyances accepted. The wise man, therefore,
+    always holds in these matters to this principle of selection: he rejects pleasures
+    to secure other greater pleasures, or else he endures pains to avoid worse pains.
+
+See also
+
+    Asemic writing – Wordless open semantic form of writing Etaoin shrdlu – Common
+    metal-type printing error Gibberish – Nonsensical speech or writing Hamburgevons –
+    Text used as a sample for assessing fonts Indian-head test pattern – Television test
+    card Lenna – Unauthorized standard test image Li Europan lingues – Placeholder text
+    in Interlingue Metasyntactic variable – Placeholder term used in computer science
+    Pangram – Sentence using every letter of alphabet
+        The quick brown fox jumps over the lazy dog – Sentence containing all letters of
+        the English alphabet
+    Shibboleth – Custom or tradition that distinguishes one group from another "To come"
+    – Phrase used in publishing to indicate missing material Utah teapot – Computer
+    graphics 3D reference and test model
+
+References
+
+Cibois, Philippe (2012-06-03). "Lorem ipsum: nouvel état de la question". L'intelligence
+du monde. L'Institut français. Retrieved 2017-04-07. Adams, Cecil (February 2001). "What
+does the filler text 'lorem ipsum' mean?". The Straight Dope. Retrieved 3 June 2022.
+"LaTeX lipsum package". Retrieved 23 September 2017. "LaTeX blind text package".
+Retrieved 23 September 2017. "How to insert sample text into a document in Word".
+Microsoft Support. 18 September 2011. Archived from the original on 12 January 2012.
+Retrieved 14 November 2011. "Description of the "Lorem ipsum dolor sit amet" text that
+appears in Word Help". support.microsoft.com. Retrieved 2007-03-22. Cicero, Marcus
+Tullius; Rackham, H. (1914). De finibus bonorum et malorum (in Latin and English). New
+York: Macmillan Co. p. 36 (Book I ix 32).
+
+    Before & After 4:2, according to Norman Walsh. "Frequently Asked Questions About
+    Fonts". Retrieved 2023-05-12.
+
+External links Lorem ipsum at Wikipedia's sister projects
+
+    Definitions from Wiktionary Media from Commons Texts from Wikisource
+
+    The original De finibus bonorum et malorum (Book 1) from Cicero, on Latin Wikisource
+
+    vte
+
+Standard test items
+
+    vte
+
+Typography Categories:
+
+    Filler textLatin words and phrasesNonsenseCicero
+
+    This page was last edited on 17 April 2025
diff --git a/perf/benchmarks/url.py b/perf/benchmarks/url.py
new file mode 100644
index 0000000..55cb20f
--- /dev/null
+++ b/perf/benchmarks/url.py
@@ -0,0 +1,28 @@
+"""
+URL methods.
+"""
+
+from blacksheep.url import URL
+from perf.benchmarks import main_run, sync_benchmark
+
+ITERATIONS = 10000
+
+
+def test_url_instantiate():
+    url = URL(b"https://www.neoteroi.dev/blacksheep/?super=yes#some-hash")
+    assert url.value == b"https://www.neoteroi.dev/blacksheep/?super=yes#some-hash"
+    assert url.host == b"www.neoteroi.dev"
+    assert url.port == 0
+    assert url.path == b"/blacksheep/"
+    assert url.query == b"super=yes"
+    assert url.is_absolute is True
+    assert url.schema == b"https"
+    assert url.fragment == b"some-hash"
+
+
+def benchmark_url_instantiate(iterations=ITERATIONS):
+    return sync_benchmark(test_url_instantiate, iterations)
+
+
+if __name__ == "__main__":
+    main_run(benchmark_url_instantiate)
diff --git a/perf/benchmarks/writeresponse.py b/perf/benchmarks/writeresponse.py
new file mode 100644
index 0000000..b92acc8
--- /dev/null
+++ b/perf/benchmarks/writeresponse.py
@@ -0,0 +1,60 @@
+"""
+Benchmarks testing functions used to write response bytes.
+"""
+
+from pathlib import Path
+
+from blacksheep.contents import TextContent
+from blacksheep.messages import Response
+from blacksheep.scribe import write_response
+from perf.benchmarks import async_benchmark, main_run
+
+ITERATIONS = 10000
+
+LOREM_IPSUM = (Path(__file__).parent / "res" / "lorem.txt").read_text(encoding="utf-8")
+RESPONSE_HEADERS = [
+    (b"Content-Type", b"text/html; charset=utf-8"),
+    (b"Content-Length", b"123"),
+    (b"Connection", b"keep-alive"),
+    (b"Cache-Control", b"no-cache, no-store, must-revalidate"),
+    (b"Pragma", b"no-cache"),
+    (b"Expires", b"0"),
+    (b"X-Frame-Options", b"DENY"),
+    (b"X-Content-Type-Options", b"nosniff"),
+    (b"X-XSS-Protection", b"1; mode=block"),
+    (b"Strict-Transport-Security", b"max-age=31536000; includeSubDomains"),
+    (b"Server", b"BlackSheep/1.0"),
+]
+
+
+async def test_write_text_response():
+    response = Response(200, RESPONSE_HEADERS).with_content(TextContent(LOREM_IPSUM))
+    data = bytearray()
+    async for chunk in write_response(response):
+        data.extend(chunk)
+    return data
+
+
+async def test_write_small_response():
+    response = Response(404, RESPONSE_HEADERS).with_content(TextContent("Not Found"))
+    data = bytearray()
+    async for chunk in write_response(response):
+        data.extend(chunk)
+    return data
+
+
+async def benchmark_write_small_response(iterations=ITERATIONS):
+    return await async_benchmark(test_write_small_response, iterations)
+
+
+async def benchmark_write_text_response(iterations=ITERATIONS):
+    return await async_benchmark(test_write_text_response, iterations)
+
+
+async def main():
+    await benchmark_write_text_response(ITERATIONS)
+    await benchmark_write_small_response(ITERATIONS)
+
+
+if __name__ == "__main__":
+    main_run(main)
diff --git a/perf/genreport.py b/perf/genreport.py
new file mode 100644
index 0000000..3d0eef4
--- /dev/null
+++ b/perf/genreport.py
@@ -0,0 +1,336 @@
+"""
+Generate an Excel report to compare benchmark results across commits.
+"""
+
+from pathlib import Path
+import argparse
+import glob
+import json
+import os
+
+import matplotlib.pyplot as plt
+import pandas as pd
+
+
+def _match_filter(data, python_filter: str, platform_filter: str):
+    if python_filter or platform_filter:
+        system_info = data["system_info"]
+        if python_filter and python_filter not in system_info["python_version"]:
+            return False
+        if platform_filter and platform_filter not in system_info["platform"]:
+            return False
+    return True
+
+
+def load_results(
+    results_dir="benchmark_results",
+    python_filter: str = "",
+    platform_filter: str = "",
+):
+    """Load all benchmark results from the directory"""
+    results = []
+    for filename in glob.glob(f"{results_dir}/blacksheep_perf_*.json"):
+        with open(filename, "r") as f:
+            data = json.load(f)
+            if not _match_filter(data, python_filter, platform_filter):
+                continue
+            data["filename"] = os.path.basename(filename)
+            results.append(data)
+
+    # Sort by commit date
+    results.sort(key=lambda x: x.get("git_info", {}).get("commit_date", ""))
+    return results
+
+
+def create_comparison_table(results):
+    """Create a pandas DataFrame for comparison"""
+    rows = []
+    for result in results:
+        commit = result.get("git_info", {}).get("commit_hash", "unknown")[:8]
+        date = result.get("git_info", {}).get("commit_date", "unknown")
+
+        row = {
+            "timestamp": result.get("timestamp", "unknown"),
+            "commit": commit,
+            "date": date,
+            "python_version": result.get("system_info", {}).get(
+                "python_version", "unknown"
+            ),
+            "platform": result.get("system_info", {}).get("platform", "unknown"),
+            "branch": result.get("git_info", {}).get("branch", "unknown"),
+        }
+
+        # Add benchmark results
+        for benchmark_name, benchmark_data in result.get("benchmarks", {}).items():
+            row[f"{benchmark_name}_avg_ms"] = benchmark_data.get("avg_time", 0) * 1000
+
+        # Add memory results
+        for mem_name, mem_data in result.get("memory_benchmarks", {}).items():
+            row[f"{mem_name}_peak_mb"] = mem_data.get("peak", 0)
+
+        rows.append(row)
+
+    return pd.DataFrame(rows)
+
+
+def _aggregate(df: pd.DataFrame, by: str):
+    if not by:
+        return df
+
+    # Aggregate results by the specified property
+    aggregation_functions = {
+        col: "mean"
+        for col in df.columns
+        if col.endswith("_avg_ms") or col.endswith("_peak_mb")
+    }
+    aggregation_functions.update(
+        {
+            "timestamp": "first",
+            "commit": "first",
+            "date": "first",
+            "branch": "first",
+            "python_version": "first",
+            "platform": "first",
+        }
+    )
+
+    # Perform the aggregation
+    aggregated_df = df.groupby(by.split(), as_index=False).agg(aggregation_functions)
+
+    # Reorder columns to match the original DataFrame
+    aggregated_df = aggregated_df[
+        [col for col in df.columns if col in aggregated_df.columns]
+    ]
+
+    # Sort by the 'date' column
+    aggregated_df = aggregated_df.sort_values(by=["date", "python_version", "platform"])
+
+    return aggregated_df
+
+
+def plot_performance_trends(df, output_file="performance_trends.png"):
+    """Create a plot showing performance trends"""
+    plt.figure(figsize=(12, 8))
+
+    # Plot time metrics
+    time_cols = [col for col in df.columns if col.endswith("_avg_ms")]
+    for col in time_cols:
+        plt.subplot(2, 1, 1)
+        plt.plot(df["date"], df[col], marker="o", label=col)
+
+    plt.title("Performance Trends Across Commits")
+    plt.ylabel("Time (ms)")
+    plt.xticks(rotation=45)
+    plt.legend()
+    plt.grid(True)
+
+    # Plot memory metrics
+    mem_cols = [col for col in df.columns if col.endswith("_peak_mb")]
+    for col in mem_cols:
+        plt.subplot(2, 1, 2)
+        plt.plot(df["date"], df[col], marker="s", label=col)
+
+    plt.ylabel("Memory (MB)")
+    plt.xticks(rotation=45)
+    plt.legend()
+    plt.grid(True)
+
+    plt.tight_layout()
+    plt.savefig(output_file)
+    print(f"Plot saved to {output_file}")
+
+
+def _set_conditional_formatting(df, worksheet, max_row):
+    all_cols = [
+        col for col in df.columns if col.endswith("_avg_ms") or col.endswith("_peak_mb")
+    ]
+    for col in all_cols:
+        col_index = df.columns.get_loc(col)  # Get the column index
+        col_letter = chr(
+            65 + col_index
+        )  # Convert column index to Excel letter (A, B, C, etc.)
+        worksheet.conditional_format(
+            f"{col_letter}2:{col_letter}{max_row + 1}",
+            {
+                "type": "3_color_scale",
+                "min_type": "min",  # Minimum value
+                "min_color": "#63BE7B",  # Green for lower values
+                "mid_type": "percentile",  # Midpoint as 50th percentile
+                "mid_value": 50,
+                "mid_color": "#FFEB84",  # Yellow for midpoint
+                "max_type": "max",  # Maximum value
+                "max_color": "#F8696B",  # Red for higher values
+            },
+        )
+
+
+def _add_ms_chart(workbook, df, worksheet, max_row):
+    time_cols = [col for col in df.columns if col.endswith("_avg_ms")]
+
+    chart = workbook.add_chart({"type": "line"})  # type: ignore
+
+    for col in time_cols:
+        col_index = df.columns.get_loc(col)  # Get the column index for the series
+        chart.add_series(
+            {
+                "name": col,
+                "categories": f"=results!$B$2:$E${(max_row + 1)}",
+                "values": [
+                    "results",
+                    1,
+                    col_index,
+                    max_row,
+                    col_index,
+                ],
+                "marker": {"type": "circle"},
+            }
+        )
+
+    # Add a chart title and some axis labels.
+    chart.set_title({"name": "Performance comparison (lower is better)"})
+    chart.set_x_axis({"name": ""})
+    chart.set_y_axis({"name": "avg ms"})
+
+    # Set an Excel chart style. Colors with white outline and shadow.
+    chart.set_style(10)
+    chart.set_size({"width": 1024, "height": 600})
+
+    # Insert the chart into the worksheet.
+    worksheet.insert_chart(f"A{max_row + 3}", chart)
+
+
+def _add_mem_chart(workbook, df, worksheet, max_row):
+    mem_cols = [col for col in df.columns if col.endswith("_peak_mb")]
+
+    chart = workbook.add_chart({"type": "line"})  # type: ignore
+
+    for col in mem_cols:
+        col_index = df.columns.get_loc(col)  # Get the column index for the series
+        chart.add_series(
+            {
+                "name": col,
+                "categories": f"=results!$B$2:$E${(max_row + 1)}",
+                "values": [
+                    "results",
+                    1,
+                    col_index,
+                    max_row,
+                    col_index,
+                ],
+                "marker": {"type": "circle"},
+            }
+        )
+
+    # Add a chart title and some axis labels.
+    chart.set_title({"name": "Mem usage comparison (lower is better)"})
+    chart.set_x_axis({"name": "commit"})
+    chart.set_y_axis({"name": "peak MB"})
+
+    # Set an Excel chart style. Colors with white outline and shadow.
+    chart.set_style(10)
+    chart.set_size({"width": 1024, "height": 600})
+
+    # Insert the chart into the worksheet.
+    worksheet.insert_chart(f"A{max_row + 35}", chart)
+
+
+def _add_charts(workbook, df, worksheet, max_row):
+    _add_ms_chart(workbook, df, worksheet, max_row)
+    _add_mem_chart(workbook, df, worksheet, max_row)
+
+
+def _set_number_format(workbook, worksheet, df):
+    # Set number format for columns with "avg_ms" or "peak_mb" in their header
+    number_format = workbook.add_format(
+        {"num_format": "0.00000000", "align": "left"}
+    )  # 8 decimal points
+    for col in df.columns:
+        if "avg_ms" in col or "peak_mb" in col:
+            col_index = df.columns.get_loc(col)  # Get the column index
+            col_letter = chr(65 + col_index)  # Convert column index to Excel letter
+            worksheet.set_column(f"{col_letter}:{col_letter}", None, number_format)
+
+
+def write_excel(df, output_file_name: str):
+    if not output_file_name.endswith(".xlsx"):
+        output_file_name = output_file_name + ".xlsx"
+    k = 0
+    orig_name = output_file_name
+    while Path(output_file_name).exists():
+        k += 1
+        output_file_name = orig_name.replace(".xlsx", f"-{k}.xlsx")
+
+    # Export to Excel for further analysis
+    writer = pd.ExcelWriter(output_file_name, engine="xlsxwriter")
+    df.to_excel(writer, index=False, sheet_name="results")
+
+    # Get the xlsxwriter workbook and worksheet objects.
+    workbook = writer.book
+    worksheet = writer.sheets["results"]
+    _set_number_format(workbook, worksheet, df)
+
+    # Get the dimensions of the dataframe.
+    (max_row, max_col) = df.shape
+
+    # Add a table to the worksheet
+    worksheet.add_table(
+        f"A1:{chr(65 + max_col - 1)}{max_row + 1}",
+        {
+            "columns": [{"header": col} for col in df.columns],
+            "style": "Table Style Medium 9",
+        },
+    )
+    # Configure the first series.
+    # Plot time metrics
+
+    _add_charts(workbook, df, worksheet, max_row)
+    _set_conditional_formatting(df, worksheet, max_row)
+
+    # Close the Pandas Excel writer and output the Excel file.
+    worksheet.autofit()
+    writer.close()
+    print(f"XLSX saved to {output_file_name}")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="Compare BlackSheep benchmark results")
+    parser.add_argument(
+        "--results-dir",
+        type=str,
+        default="./benchmark_results",
+        help="Directory containing benchmark results",
+    )
+    parser.add_argument(
+        "--group-by",
+        type=str,
+        default="commit python_version platform",
+        help="Group by",
+    )
+    parser.add_argument(
+        "--python",
+        type=str,
+        default="",
+        help="Filter by Python version",
+    )
+    parser.add_argument(
+        "--platform",
+        type=str,
+        default="",
+        help="Filter by platform",
+    )
+    parser.add_argument(
+        "--output",
+        type=str,
+        default="performance-comparison.xlsx",
+        help="Output file name",
+    )
+    args = parser.parse_args()
+
+    results = load_results(args.results_dir, args.python, args.platform)
+    if not results:
+        print(f"No benchmark results found in {args.results_dir}")
+        exit(0)
+
+    df = create_comparison_table(results)
+    df = _aggregate(df, args.group_by)
+    write_excel(df, args.output)
diff --git a/perf/historyrun.py b/perf/historyrun.py
new file mode 100644
index 0000000..7bf6c13
--- /dev/null
+++ b/perf/historyrun.py
@@ -0,0 +1,167 @@
+"""
+This script allows to execute the same performance tests on multiple
+versions of BlackSheep. It creates a temporary copy of the perf folder
+to ensure that the same tests are executed at various points of the
+Git history.
+
+python perf/historyrun.py --commits 82ed065 1237b1e
+"""
+
+import argparse
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+logger.addHandler(logging.StreamHandler())
+logger.setLevel(logging.DEBUG)
+
+
+def copy_perf_code(temp_dir):
+    """
+    Copies the 'perf' folder into a temporary directory to ensure the same
+    performance code is used across different commits.
+    """
+    source_dir = os.path.abspath("perf")  # Path to the 'perf' folder
+    if not os.path.exists(source_dir):
+        logger.error("Source directory '%s' does not exist.", source_dir)
+        sys.exit(1)
+
+    dest_dir = os.path.join(temp_dir, "perf")
+
+    # Copy the 'perf' folder recursively
+    shutil.copytree(source_dir, dest_dir)
+
+    logger.debug("'perf' folder copied to temporary directory: %s", dest_dir)
+    return dest_dir
+
+
+def restore_perf_code(temp_dir):
+    logger.info("Replacing the local 'perf' folder with the backup...")
+
+    # Path to the local 'perf' folder
+    local_perf_dir = os.path.abspath("perf")
+
+    # Delete the local 'perf' folder if it exists
+    if os.path.exists(local_perf_dir):
+        logger.info("Deleting the local 'perf' folder: %s", local_perf_dir)
+        shutil.rmtree(local_perf_dir)
+
+    # Path to the backup 'perf' folder in the temporary directory
+    source_dir = os.path.join(temp_dir, "perf")
+
+    # Copy the backup 'perf' folder to the current directory
+    shutil.copytree(source_dir, local_perf_dir)
+    logger.info("Restored 'perf' folder from backup: %s", source_dir)
+
+
+@contextmanager
+def gitcontext():
+    branch = subprocess.check_output(
+        ["git", "rev-parse", "--abbrev-ref", "HEAD"], universal_newlines=True
+    ).strip()
+    try:
+        yield branch
+    except:
+        # go back to the original branch
+        pass
+    logger.info("Returning to the original branch")
+    subprocess.check_output(["git", "checkout", "-f", branch], universal_newlines=True)
+
+
+def make_compile():
+    logger.info("Compiling BlackSheep extensions")
+    # TODO: use other commands to support Windows
+    subprocess.check_output(["make", "compile"], universal_newlines=True)
+
+
+def run_tests(iterations: int, output_dir: str, times: int):
+    subprocess.check_output(
+        [
+            "python",
+            "perf/main.py",
+            "--iterations",
+            str(iterations),
+            "--output-dir",
+            output_dir,
+            "--times",
+            str(times),
+        ],
+        universal_newlines=True,
+    )
+
+
+def copy_results(source_dir, dest_dir):
+    """
+    Copies all files from the source directory to the destination directory.
+    If the destination directory does not exist, it is created.
+    """
+    if not os.path.exists(source_dir):
+        logger.error("Source directory '%s' does not exist.", source_dir)
+        sys.exit(1)
+
+    if not os.path.exists(dest_dir):
+        os.makedirs(dest_dir)
+        logger.info("Created destination directory: %s", dest_dir)
+
+    # Copy all files and subdirectories
+    for item in os.listdir(source_dir):
+        source_path = os.path.join(source_dir, item)
+        dest_path = os.path.join(dest_dir, item)
+
+        if os.path.isdir(source_path):
+            shutil.copytree(source_path, dest_path)
+        else:
+            shutil.copy2(source_path, dest_path)
+
+    logger.info("Copied all files from '%s' to '%s'", source_dir, dest_dir)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="BlackSheep Performance Benchmarking")
+    parser.add_argument(
+        "--iterations", type=int, default=1000000, help="Number of iterations"
+    )
+    parser.add_argument(
+        "--times", type=int, default=5, help="How many runs for each commit"
+    )
+    parser.add_argument(
+        "--commits",
+        type=str,
+        nargs="+",  # Accept one or more commit SHAs
+        help="List of Git commit SHAs to benchmark",
+    )
+    args = parser.parse_args()
+
+    # Example usage of the commits argument
+    if args.commits:
+        print(f"Commits to benchmark: {args.commits}")
+    else:
+        print("No commits provided.")
+        sys.exit(1)
+
+    with tempfile.TemporaryDirectory() as temp_dir:
+        output_dir = Path(temp_dir) / "results"
+        copy_perf_code(temp_dir)
+
+        with gitcontext():
+            for commit in args.commits:
+                subprocess.check_output(
+                    ["git", "checkout", "-f", commit], universal_newlines=True
+                )
+                logger.info("Checked out commit: %s", commit)
+                make_compile()
+                restore_perf_code(temp_dir)
+                run_tests(args.iterations, str(output_dir), args.times)
+
+        # Copy the results from output_dir to ./benchmark_results
+        copy_results(str(output_dir), "./benchmark_results")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/perf/main.py b/perf/main.py
new file mode 100644
index 0000000..4c9a17c
--- /dev/null
+++ b/perf/main.py
@@ -0,0 +1,197 @@
+"""
+BlackSheep performance benchmarking suite.
+Refer to the README for details.
+"""
+
+import argparse
+import asyncio
+import gc
+import importlib
+import inspect
+import json
+import os
+import platform
+import subprocess
+import sys
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+import psutil
+
+from blacksheep import __version__ as blacksheep_version
+
+
+def collect_benchmark_functions(package_name: str):
+    """
+    Collects all functions whose names start with 'benchmark_' from the modules
+    inside the specified package.
+
+    Args:
+        package_name (str): The name of the package to search for benchmark functions.
+
+    Returns:
+        dict: A dictionary where keys are function names and values are the function objects.
+    """
+    benchmark_functions = {}
+    package_path = Path(__file__).parent / package_name
+
+    if not package_path.exists() or not package_path.is_dir():
+        raise ValueError(f"Package '{package_name}' not found at {package_path}")
+
+    # Iterate over all Python files in the package
+    for module_path in package_path.glob("*.py"):
+        module_name = f"{package_name}.{module_path.stem}"
+        module = importlib.import_module(module_name)
+
+        # Inspect the module for functions starting with 'benchmark_'
+        for name, func in inspect.getmembers(module, inspect.isfunction):
+            if name.startswith("benchmark_"):
+                # Strip the prefix
+                benchmark_functions[name[10:]] = func
+
+    return benchmark_functions
+
+
+def get_memory_usage(func):
+    # A subprocess is necessary to obtain correct memory usage
+    script = f"""
+import asyncio
+import gc
+import json
+from memory_profiler import memory_usage
+
+from perf.{func.__module__} import {func.__name__} as func
+
+gc.collect()
+
+def wrapper():
+    if asyncio.iscoroutinefunction(func):
+        asyncio.run(func())
+    else:
+        return func()
+
+mem_usage = memory_usage(wrapper, interval=0.01, timeout=30)
+print(json.dumps({{
+    "peak": max(mem_usage),
+    "avg": sum(mem_usage) / len(mem_usage),
+    "min": min(mem_usage),
+    "samples": len(mem_usage),
+}}))
+"""
+    result = subprocess.run(
+        ["python", "-c", script],
+        capture_output=True,
+        text=True,
+    )
+    result.check_returncode()  # raise if something went wrong
+    return json.loads(result.stdout)
+
+
+def get_git_info():
+    try:
+        commit_hash = subprocess.check_output(
+            ["git", "rev-parse", "HEAD"], universal_newlines=True
+        ).strip()
+        commit_date = subprocess.check_output(
+            ["git", "show", "-s", "--format=%ci", commit_hash], universal_newlines=True
+        ).strip()
+        branch = subprocess.check_output(
+            ["git", "rev-parse", "--abbrev-ref", "HEAD"], universal_newlines=True
+        ).strip()
+
+        return {
+            "commit_hash": commit_hash,
+            "commit_date": commit_date,
+            "branch": branch,
+        }
+    except subprocess.CalledProcessError:
+        return {"error": "Not a git repository or git not available"}
+
+
+# Get system information
+def get_system_info():
+    return {
+        "cpu_count": os.cpu_count(),
+        "memory_total": psutil.virtual_memory().total,
+        "python_version": sys.version.split()[0],
+        "blacksheep_version": blacksheep_version,
+        "platform": platform.platform(),
+    }
+
+
+async def run_all_benchmarks(iterations: int, key: str):
+    results = {
+        "timestamp": datetime.now().isoformat(),
+        "git_info": get_git_info(),
+        "system_info": get_system_info(),
+        "benchmarks": {},
+        "memory_benchmarks": {},
+    }
+
+    # Collect all benchmark functions
+    benchmark_functions = collect_benchmark_functions("benchmarks")
+    print(f"Found {len(benchmark_functions)} benchmarks.")
+
+    # Time benchmarks
+    for name, func in benchmark_functions.items():
+        if key and key not in name:
+            continue
+
+        print(f"Running benchmark: {name}...")
+        if asyncio.iscoroutinefunction(func):
+            results["benchmarks"][name] = await func(iterations)
+        else:
+            results["benchmarks"][name] = func(iterations)
+
+    # Memory benchmarks
+    for name, func in benchmark_functions.items():
+        if key and key not in name:
+            continue
+        gc.collect()
+        print(f"Running memory benchmark: {name}...")
+        results["memory_benchmarks"][name] = get_memory_usage(func)
+
+    return results
+
+
+def save_results(results, output_dir="./benchmark_results"):
+    Path(output_dir).mkdir(exist_ok=True)
+
+    # Generate a filename with git hash and timestamp
+    git_hash = results["git_info"].get("commit_hash", "no-git")[:8]
+    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+    unique_id = uuid.uuid4().hex[:8]  # Generate a short UUID
+
+    filename = f"{output_dir}/blacksheep_perf_{git_hash}_{timestamp}_{unique_id}.json"
+
+    with open(filename, "w") as f:
+        json.dump(results, f, indent=2)
+
+    print(f"Results saved to {filename}")
+    return filename
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="BlackSheep Performance Benchmarking")
+    parser.add_argument(
+        "--iterations", type=int, default=100000, help="Number of iterations"
+    )
+    parser.add_argument(
+        "--times", type=int, default=1, help="How many times to run the benchmarks"
+    )
+    parser.add_argument(
+        "--output-dir", type=str, default="./benchmark_results", help="Output directory"
+    )
+    parser.add_argument(
+        "--filter",
+        "-f",
+        type=str,
+        default="",
+        help="Optional filter to run specific benchmarks",
+    )
+    args = parser.parse_args()
+
+    for _ in range(args.times):
+        results = asyncio.run(run_all_benchmarks(args.iterations, args.filter))
+        save_results(results, args.output_dir)
diff --git a/perf/req.txt b/perf/req.txt
new file mode 100644
index 0000000..1a9b6a0
--- /dev/null
+++ b/perf/req.txt
@@ -0,0 +1,6 @@
+matplotlib==3.10.1
+memory-profiler==0.61.0
+pandas==2.2.3
+psutil==7.0.0
+line_profiler==4.2.0
+XlsxWriter==3.2.3


Test patch:
diff --git a/pytest.ini b/pytest.ini
index 6872f7d..a02bf0c 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,3 +1,4 @@
 [pytest]
 junit_family=xunit1
 asyncio_mode=auto
+addopts = --ignore=perf