Skip to content

Commit 62bc7d8

Browse files
Basic stress testing and JWT/MSAL usage optimizations (#175)
* Adding JWK client initialization code to cache keys * Adding click as a dev dependency * Wrapping JWT keys request with asyncio functionality to not block thread * Adding simple benchmarking tool * Adding asyncio wrapper around msal methods * Improved benchmark code * Another commit * Removing breakpoints * Using expect_replies in payload * Updating README.md * Removing unused file * Changes to async executor * Small tweak to driver * Fixing incorrect call replacement * Commenting out unfinished Coroutine executor definition * Addressing copilot review comments * Cleanup * Removed unneeded comment * Addressing PR comments * Removing unnecessary caching of the jwk's client * Formatting --------- Co-authored-by: Axel Suárez <[email protected]>
1 parent ddd472c commit 62bc7d8

File tree

22 files changed

+548
-40
lines changed

22 files changed

+548
-40
lines changed

dev/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
This directory contains tools to aid the developers of the Microsoft 365 Agents SDK for Python.
2+
3+
### `benchmark`
4+
5+
This folder contains benchmarking utilities built in Python to send concurrent requests
6+
to an agent.

dev/benchmark/README.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
A simple benchmarking tool.
2+
3+
## Benchmark Python Environment Manual Setup (Windows)
4+
5+
Currently a version of this tool that spawns async workers/coroutines instead of
6+
concurrent threads is not supported, so if you use a "normal" (non free-threaded) version
7+
of Python, you will be running with the global interpreter lock (GIL).
8+
9+
Note: This may or may not incur significant changes in performance over using
10+
free-threaded concurrent tests or async workers, depending on the test scenario.
11+
12+
Install any Python version >= 3.9. Check with:
13+
14+
```bash
15+
python --version
16+
```
17+
18+
Then, set up and activate the virtual environment with:
19+
20+
```bash
21+
python -m venv venv
22+
. ./venv/Scripts/activate
23+
pip install -r requirements.txt
24+
```
25+
26+
To activate the virtual environment, use:
27+
28+
```bash
29+
. ./venv/Scripts/activate
30+
```
31+
32+
To deactivate it, you may use:
33+
34+
```bash
35+
deactivate
36+
```
37+
38+
## Benchmark Python Environment Setup (Windows) - Free Threaded Python
39+
40+
Traditionally, most Python versions have a global interpreter lock (GIL) which prevents
41+
more than 1 thread to run at the same time. With 3.13, there are free-threaded versions
42+
of Python which allow one to bypass this constraint. This section walks through how
43+
to do that on Windows. Use PowerShell.
44+
45+
Based on: https://docs.python.org/3/using/windows.html#
46+
47+
Go to `Microsoft Store` and install `Python Install Manager` and follow the instructions
48+
presented. You may have to make certain changes to alias used by your machine (that
49+
should be guided by the installation process).
50+
51+
Based on: https://docs.python.org/3/whatsnew/3.13.html#free-threaded-cpython
52+
53+
In PowerShell, install the free-threaded version of Python of your choice. In this guide
54+
we will install `3.14t`:
55+
56+
```bash
57+
py install 3.14t
58+
```
59+
60+
Then, set up and activate the virtual environment with:
61+
62+
```bash
63+
python3.14t -m venv venv
64+
. ./venv/Scripts/activate
65+
pip install -r requirements.txt
66+
```
67+
68+
To activate the virtual environment, use:
69+
70+
```bash
71+
. ./venv/Scripts/activate
72+
```
73+
74+
To deactivate it, you may use:
75+
76+
```bash
77+
deactivate
78+
```
79+
80+
## Benchmark Configuration
81+
82+
If you open the `env.template` file, you will see three environmental variables to define:
83+
84+
```bash
85+
TENANT_ID=
86+
APP_ID=
87+
APP_SECRET=
88+
```
89+
90+
For `APP_ID` use the app Id of your ABS resource. For `APP_SECRET` set it to a secret
91+
for the App Registration resource tied to your ABS resource. Finally, the `TENANT_ID`
92+
variable should be set to the tenant Id of your ABS resource.
93+
94+
These settings are used to generate valid tokens that are sent and validated by the
95+
agent you are trying to run.
96+
97+
## Usage
98+
99+
Running these tests requires you to have the agent running in a separate process. You
100+
may open a separate PowerShell window or VSCode window and run your agent there.
101+
102+
To run the basic payload sending stress test (our only implemented test so far), use:
103+
104+
```bash
105+
. ./venv/Scripts/activate # activate the virtual environment if you haven't already
106+
python -m src.main --num_workers=...
107+
```

dev/benchmark/env.template

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
TENANT_ID=
2+
APP_ID=
3+
APP_SECRET=

dev/benchmark/payload.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"channelId": "msteams",
3+
"serviceUrl": "http://localhost:49231/_connector",
4+
"delivery_mode": "expectReplies",
5+
"recipient": {
6+
"id": "00000000-0000-0000-0000-00000000000011",
7+
"name": "Test Bot"
8+
},
9+
"conversation": {
10+
"id": "personal-chat-id",
11+
"conversationType": "personal",
12+
"tenantId": "00000000-0000-0000-0000-0000000000001"
13+
},
14+
"from": {
15+
"id": "user-id-0",
16+
"aadObjectId": "00000000-0000-0000-0000-0000000000020"
17+
},
18+
"type": "message"
19+
}

dev/benchmark/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
microsoft-agents-activity
2+
microsoft-agents-hosting-core
3+
click
4+
azure-identity

dev/benchmark/src/__init__.py

Whitespace-only changes.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from .executor import ExecutionResult
2+
3+
4+
class AggregatedResults:
5+
"""Class to analyze execution time results."""
6+
7+
def __init__(self, results: list[ExecutionResult]):
8+
self._results = results
9+
10+
self.average = sum(r.duration for r in results) / len(results) if results else 0
11+
self.min = min((r.duration for r in results), default=0)
12+
self.max = max((r.duration for r in results), default=0)
13+
self.success_count = sum(1 for r in results if r.success)
14+
self.failure_count = len(results) - self.success_count
15+
self.total_time = sum(r.duration for r in results)
16+
17+
def display(self, start_time: float, end_time: float):
18+
"""Display aggregated results."""
19+
print()
20+
print("---- Aggregated Results ----")
21+
print()
22+
print(f"Average Time: {self.average:.4f} seconds")
23+
print(f"Min Time: {self.min:.4f} seconds")
24+
print(f"Max Time: {self.max:.4f} seconds")
25+
print()
26+
print(f"Success Rate: {self.success_count} / {len(self._results)}")
27+
print()
28+
print(f"Total Time: {end_time - start_time} seconds")
29+
print("----------------------------")
30+
print()
31+
32+
def display_timeline(self):
33+
"""Display timeline of individual execution results."""
34+
print()
35+
print("---- Execution Timeline ----")
36+
print(
37+
"Each '.' represents 1 second of successful execution. So a line like '...' is a success that took 3 seconds (rounded up), 'x' represents a failure."
38+
)
39+
print()
40+
for result in sorted(self._results, key=lambda r: r.exe_id):
41+
c = "." if result.success else "x"
42+
if c == ".":
43+
duration = int(round(result.duration))
44+
for _ in range(1 + duration):
45+
print(c, end="")
46+
print()
47+
else:
48+
print(c)
49+
50+
print("----------------------------")
51+
print()

dev/benchmark/src/config.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
from dotenv import load_dotenv
3+
4+
load_dotenv()
5+
6+
7+
class BenchmarkConfig:
8+
"""Configuration class for benchmark settings."""
9+
10+
TENANT_ID: str = ""
11+
APP_ID: str = ""
12+
APP_SECRET: str = ""
13+
AGENT_API_URL: str = ""
14+
15+
@classmethod
16+
def load_from_env(cls) -> None:
17+
"""Loads configuration values from environment variables."""
18+
cls.TENANT_ID = os.environ.get("TENANT_ID", "")
19+
cls.APP_ID = os.environ.get("APP_ID", "")
20+
cls.APP_SECRET = os.environ.get("APP_SECRET", "")
21+
cls.AGENT_URL = os.environ.get(
22+
"AGENT_API_URL", "http://localhost:3978/api/messages"
23+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .coroutine_executor import CoroutineExecutor
2+
from .execution_result import ExecutionResult
3+
from .executor import Executor
4+
from .thread_executor import ThreadExecutor
5+
6+
__all__ = [
7+
"CoroutineExecutor",
8+
"ExecutionResult",
9+
"Executor",
10+
"ThreadExecutor",
11+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import asyncio
5+
from typing import Callable, Awaitable, Any
6+
7+
from .executor import Executor
8+
from .execution_result import ExecutionResult
9+
10+
11+
class CoroutineExecutor(Executor):
12+
"""An executor that runs asynchronous functions using asyncio."""
13+
14+
def run(
15+
self, func: Callable[[], Awaitable[Any]], num_workers: int = 1
16+
) -> list[ExecutionResult]:
17+
"""Run the given asynchronous function using the specified number of coroutines.
18+
19+
:param func: An asynchronous function to be executed.
20+
:param num_workers: The number of coroutines to use.
21+
"""
22+
23+
async def gather():
24+
return await asyncio.gather(
25+
*[self.run_func(i, func) for i in range(num_workers)]
26+
)
27+
28+
return asyncio.run(gather())

0 commit comments

Comments
 (0)