Skip to content

[Bug]: Episodic memory search's respond time increases drastically when number of concurrency increases #837

Description

@szou-mv

Describe the bug

Following is the respond time chart of load test of POST /memories/search with types = ['episodic'].
When number of user is less than 10, the response time doesn't significantly raise.
However, when number of user reached about 12, the response time raised drastically.
When the number of user reached 30, it took about half a minute to return.
And then as a result, we can only handle 2~3 search requests per second.
(Pure semantic memory search doesn't have this issue)

Image

Steps to reproduce

  1. Load testing tool: locust.
  2. API: POST /memories/search payload: types = ['episodic'].
  3. There are 407 episodic memories in a single project.
  4. Every user will send requests every 1~3 seconds.
  5. Example payload:
    {"org_id": "org-bedbug", "project_id": "default", "query": "how often should I provide examples?", "top_k": 14, "types": ["episodic"]}
  6. Full API response time report.
    loadtest_20251219_120117_test_info.log

Expected behavior

If we can't handle big concurrency, maybe we should add throttling mechanisms.

Environment

Memmachine Version: 65578fc

Env:
MacOS(M2chip 16GB ram)
Embedding model and memmachine are deployed on the same host.
embedding model: embeddinggemma:latest (ollama)
No significant memory/GPU pressure found during load testing.

Image Image

Additional context

No response

Activity

  1. added theissue type on Dec 19, 2025
  2. sscargal commented on Dec 19, 2025

    @sscargal
    Contributor

    Thanks @szou-mv. I did similar Locust testing on MemMachine v0.1.x some time ago and found similar scalability problems. Here is what I saw:

    Image

    Conclusion: Response times exceed 1 second when the number of users increases from 50 to 100. On my SUT, 50 active users is the maximum.

    IMHO, the critical metric is Requests Per Second (RPS), rather than Number of Users. My RPS results were low. My results were better on x86 than on ARM (Mac).

    My SUT:

    • OS: Ubuntu 25.04 x86_64
    • Kernel: Linux 6.14.0-29-generic
    • CPU: AMD Ryzen 7 7735HS (x4) @ 3.19 GHz
    • Memory: 8 GiB

    Suggested Next Steps: Follow the tips outlined in Scaling a FastAPI Application: Handling Multiple Requests at Once to improve FastAPI/FastMCP performance to handle more clients. Enabling more workers should help and scale to the number of available CPUs. By default, FastAPI/FastMCP is single-threaded.

  3. self-assigned this
    on Dec 19, 2025
  4. szou-mv commented on Dec 24, 2025

    @szou-mv
    Author

    More testing data:
    During pure read load testing (/api/v2/memories/search),
    When number of concurrency increases, the throughput(RPS) drops.

    Concurrency Throughput (RPS) Note
    1 2.08 Baseline
    5 2.65 Best Performance
    10 2.02 Performance starts to degrade
    50 1.08 Critical Drop (~50% of baseline)

    Observation: At 50 concurrency, the RPS (1.08) is significantly lower than single-thread execution (2.08). This implies that processing 50 requests sequentially (one by one) would actually be faster than handling them in parallel. This indicates severe resource contention or locking issues within the server.

    Test Methodology: For each concurrency setting, 100+ requests were executed to ensure statistical significance and rule out variance or cold-start anomalies.

    Expected Behavior: Throughput should increase or plateau as concurrency rises. It should strictly NOT drop below the single-thread baseline.

    RPS vs concurrency (1~10)
    Image

    RPS vs concurrency (10~50)
    Image

  5. sscargal commented on Dec 31, 2025

    @sscargal
    Contributor

    I experimented with increasing the number of uvicorn workers for the memmachine-app FastAPI server.

    Here is the Locust run with the default single-process (existing behavior).

    Image

    Here is the Locust run with 7 workers on an 8 vCPU server

    Image

    Here are the Locust report details

    Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
    --------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
    POST     /api/v2/memories [add assistant msg]                                            6612   111(1.68%) |  11964       3  123983   6700 |    8.49        0.14
    POST     /api/v2/memories [add user msg]                                                 6807    99(1.45%) |  11748       0  128082   6700 |    8.74        0.13
    POST     /api/v2/memories/search                                                         1230    24(1.95%) |  25387       4  140393  13000 |    1.58        0.03
    POST     /api/v2/projects [create]                                                        500     0(0.00%) |   1568       5   15907    700 |    0.64        0.00
    --------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
             Aggregated                                                                     15149   234(1.54%) |  12614       0  140393   6800 |   19.44        0.30
    
    Response time percentiles (approximated)
    Type     Name                                                                                  50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
    --------|--------------------------------------------------------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------
    POST     /api/v2/memories [add assistant msg]                                                 6700  13000  17000  20000  31000  41000  50000  57000 116000 124000 124000   6612
    POST     /api/v2/memories [add user msg]                                                      6700  13000  17000  20000  29000  39000  50000  56000 116000 128000 128000   6807
    POST     /api/v2/memories/search                                                             13000  26000  40000  46000  64000  82000  95000 118000 139000 140000 140000   1230
    POST     /api/v2/projects [create]                                                             700   1500   2300   2700   4000   5400   7900  15000  16000  16000  16000    500
    --------|--------------------------------------------------------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------
             Aggregated                                                                           6800  13000  17000  20000  32000  44000  57000  77000 121000 139000 140000  15149
    
    Error report
    # occurrences      Error                                                                                               
    ------------------|---------------------------------------------------------------------------------------------------------------------------------------------
    38                 POST /api/v2/memories [add assistant msg]: HTTPError('500 Server Error: Internal Server Error for url: /api/v2/memories [add assistant msg]')
    50                 POST /api/v2/memories [add user msg]: RemoteDisconnected('Remote end closed connection without response')
    49                 POST /api/v2/memories [add user msg]: HTTPError('500 Server Error: Internal Server Error for url: /api/v2/memories [add user msg]')
    73                 POST /api/v2/memories [add assistant msg]: RemoteDisconnected('Remote end closed connection without response')
    21                 POST /api/v2/memories/search: RemoteDisconnected('Remote end closed connection without response')   
    3                  POST /api/v2/memories/search: HTTPError('500 Server Error: Internal Server Error for url: /api/v2/memories/search')
    ------------------|---------------------------------------------------------------------------------------------------------------------------------------------
    

    Contrary to expectations, adding more workers hurts performance. We don't see any significant increase in Requests per Second (RPS), but response times have increased significantly, indicating we're likely now dealing with a Database/Back-end problem.

    Next Actions:

    • Profile memmachine-app to understand where we are spending the majority of the time
    • There is serialization within the core code.
    • Profile the Postgres and Neo4J databases to see where the bottleneck may be. DBs are not tuned out of the box.

    locustfile.py

    Here is the locustfile.py that uses the client REST APIs

    import random
    import uuid
    import datetime
    import os
    from locust import HttpUser, task, between, SequentialTaskSet, LoadTestShape
    from faker import Faker
    
    fake = Faker()
    
    # Configuration
    ORG_ID = "benchmark_org"
    PROJECT_ID = "benchmark_project"
    # Default to localhost:8080 if not specified
    DEFAULT_HOST = os.getenv("MEMMACHINE_URL", "http://localhost:8080")
    
    class ChatSessionBehavior(SequentialTaskSet):
        """
        Base behavior for chat sessions.
        """
        session_id = None
        num_messages = 0
    
        def on_start(self):
            self.session_id = str(uuid.uuid4())
            # Ensure project exists (idempotent check usually done at user start, but safe to assume here)
        
        def simulate_message_pair(self):
            """Send a user message and then an assistant message."""
            # 1. User Message
            user_content = fake.sentence(nb_words=10)
            user_msg = {
                "content": user_content,
                "role": "user",
                "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
                "metadata": {"session_id": self.session_id}
            }
            
            self.client.post(
                "/api/v2/memories",
                json={
                    "org_id": ORG_ID,
                    "project_id": PROJECT_ID,
                    "messages": [user_msg]
                },
                name="/api/v2/memories [add user msg]"
            )
    
            # 2. Assistant Message
            asst_content = fake.sentence(nb_words=15)
            asst_msg = {
                "content": asst_content,
                "role": "assistant",
                "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
                "metadata": {"session_id": self.session_id}
            }
    
            self.client.post(
                "/api/v2/memories",
                json={
                    "org_id": ORG_ID,
                    "project_id": PROJECT_ID,
                    "messages": [asst_msg]
                },
                name="/api/v2/memories [add assistant msg]"
            )
    
        def simulate_search(self):
            """Perform a search related to the session."""
            query = fake.sentence(nb_words=5).replace(".", "?")
            self.client.post(
                "/api/v2/memories/search",
                json={
                    "org_id": ORG_ID,
                    "project_id": PROJECT_ID,
                    "query": query,
                    "top_k": 5,
                    "filter": f"metadata.session_id='{self.session_id}'"
                },
                name="/api/v2/memories/search"
            )
    
    class ShortChatSession(ChatSessionBehavior):
        @task
        def run_session(self):
            self.on_start()
            # 3-5 message pairs
            num_pairs = random.randint(3, 5)
            for _ in range(num_pairs):
                self.simulate_message_pair()
            
            # 1 search at the end
            self.simulate_search()
            self.interrupt()
    
    class LongChatSession(ChatSessionBehavior):
        @task
        def run_session(self):
            self.on_start()
            # 15-20 message pairs
            num_pairs = random.randint(15, 20)
            
            for i in range(num_pairs):
                self.simulate_message_pair()
                # Search every ~5 messages
                if (i + 1) % 5 == 0:
                    self.simulate_search()
            
            self.interrupt()
    
    class MemMachineUser(HttpUser):
        wait_time = between(1, 3)
        host = DEFAULT_HOST
        
        tasks = {
            ShortChatSession: 3,
            LongChatSession: 1
        }
    
        def on_start(self):
            """Initialize user and ensure project exists."""
            # Try to create project, ignore 409 (already exists)
            with self.client.post(
                "/api/v2/projects",
                json={
                    "org_id": ORG_ID,
                    "project_id": PROJECT_ID,
                    "description": "Benchmark Project"
                },
                name="/api/v2/projects [create]",
                catch_response=True
            ) as response:
                if response.status_code == 409:
                    response.success()
                elif response.status_code >= 400:
                    response.failure(f"Failed to create project: {response.text}")
    
    class RampUpShape(LoadTestShape):
        """
        A step load shape that ramps up users from 0 to 500.
        
        Stages:
        1. Warm up: 10 users (1 min)
        2. Low load: 50 users (2 mins)
        3. Medium load: 100 users (2 mins)
        4. High load: 250 users (3 mins)
        5. Peak load: 500 users (5 mins)
        """
        stages = [
            {"duration": 60, "users": 10, "spawn_rate": 2},
            {"duration": 180, "users": 50, "spawn_rate": 5},
            {"duration": 300, "users": 100, "spawn_rate": 5},
            {"duration": 480, "users": 250, "spawn_rate": 10},
            {"duration": 780, "users": 500, "spawn_rate": 10},
        ]
    
        def tick(self):
            run_time = self.get_run_time()
    
            for stage in self.stages:
                if run_time < stage["duration"]:
                    tick_data = (stage["users"], stage["spawn_rate"])
                    return tick_data
    
            return None
    

    app.py changes

    Changes to app.py to use the new workers and switch from uvicorn.Server(...).serve() (Original) to uvicorn.run("module:app", ...). The original .serve() doesn't support workers, whereas uvicorn.run does.

    $ git diff src/memmachine/server/app.py
    diff --git a/src/memmachine/server/app.py b/src/memmachine/server/app.py
    index ee73a21..3a903ae 100644
    --- a/src/memmachine/server/app.py
    +++ b/src/memmachine/server/app.py
    @@ -14,6 +14,7 @@ It includes:
     import argparse
     import asyncio
     import logging
    +import os
     import sys
     import time
     from collections.abc import Callable
    @@ -104,20 +105,6 @@ async def access_log_middleware(request: Request, call_next: Callable) -> Respon
         return response
     
     
    -async def start() -> None:
    -    """Run the FastAPI application using uvicorn server."""
    -    config = load_configuration()
    -    await uvicorn.Server(
    -        uvicorn.Config(
    -            app,
    -            host=config.server.host,
    -            port=config.server.port,
    -            access_log=True,
    -            log_level=str(config.logging.level).lower(),
    -        ),
    -    ).serve()
    -
    -
     def main() -> None:
         """Execute the CLI entry point for the application."""
         # Load environment variables from .env file
    @@ -127,6 +114,10 @@ def main() -> None:
         else:
             load_dotenv()
     
    +    # Configure basic logging to ensure we see startup messages
    +    logging.basicConfig(level=logging.INFO)
    +    print("DEBUG: memmachine-server entrypoint called", file=sys.stderr)
    +
         # Parse command line arguments
         parser = argparse.ArgumentParser(description="MemMachine server")
         parser.add_argument(
    @@ -147,7 +138,30 @@ def main() -> None:
                 asyncio.run(run_mcp_server())
             else:
                 # HTTP mode for REST API
    -            asyncio.run(start())
    +            config = load_configuration()
    +
    +            # Determine number of workers
    +            workers_env = os.getenv("MEMMACHINE_WORKERS")
    +            if workers_env:
    +                workers = int(workers_env)
    +            else:
    +                # Default to max(1, cpu_count - 1)
    +                cpu_count = os.cpu_count() or 1
    +                workers = max(1, cpu_count - 1)
    +                print(f"DEBUG: Calculated workers based on cpu_count={cpu_count}: {workers}", file=sys.stderr)
    +
    +            logger.info(f"Starting server with {workers} workers")
    +            print(f"DEBUG: Starting server with {workers} workers (stderr)", file=sys.stderr)
    +
    +            # Use uvicorn.run() to correctly handle multiprocessing
    +            uvicorn.run(
    +                "memmachine.server.app:app",
    +                host=config.server.host,
    +                port=config.server.port,
    +                workers=workers,
    +                access_log=True,
    +                log_level=str(config.logging.level).lower(),
    +            )
         except KeyboardInterrupt:
             logger.warning("Application cancelled by user.")
             sys.exit(130)  # Standard exit code for Ctrl+C
    
  6. jgong commented on Jan 2, 2026

    @jgong
    Contributor

    Hi @sscargal, thanks very much for running the experiments and sharing the results on scaling with additional workers. Based on your suggestion, would it be helpful to involve @malatewang and @edwinyyyu in the profiling effort of memmachine-app and databases? CC @YTLiangMV

  7. sscargal commented on Jan 6, 2026

    @sscargal
    Contributor

    This is a phased approach.

    Phase 1: I created Feature Request #902 and PR #903 to allow users to create a FastAPI worker pool (sub-processes). This allows FastAPI to handle more inbound user requests.

    Future phases will address downstream/internal performance bottlenecks.

  8. sscargal commented on Jan 6, 2026

    @sscargal
    Contributor

    locustfile.py

    Download: locustfile.py

    I have updated the testing and profiling tools to support more targeted benchmarks and deeper performance analysis.

    Refactored to support configurable workloads via the LOCUST_WORKLOAD environment variable.

    Workload Env Variable Behavior
    Mixed LOCUST_WORKLOAD=mixed (Default) Simulates standard chat behavior: User/Assistant messages + periodic searches.
    Write-Only LOCUST_WORKLOAD=write-only Simulates high-volume ingestion: Only sends User/Assistant messages. No search.
    Read-Only LOCUST_WORKLOAD=read-only Simulates heavy retrieval: Only search queries. Assumes existing data.

    How to run (CLI):

    # Run mixed workload (default)
    locust -f locustfile.py
    
    # Run write-only workload
    locust -f locustfile.py --workload write-only

    How to use (Browser UI):

    1. Start locust: locust -f locustfile.py
    2. Open http://localhost:8089
    3. You will see a Workload drop-down field in the startup form. Select mixed, write-only, or read-only.
    4. You will see a Memory Types drop-down field. Select all, episodic, or semantic.

    profile_memmachine.sh

    Download: profile_memmachine.sh

    Enhanced to support speedscope output format, which allows for interactive, zooming flamecharts (essential for finding serialization issues).

    IMPORTANT
    The profiling script uses py-spy, which requires the SYS_PTRACE capability. I have added cap_add: [SYS_PTRACE] to
    docker-compose.yml. You MUST restart your container for this to take effect: docker compose up -d

    Arguments:

    • -f speedscope: Output JSON for speedscope.app.
    • -f flamegraph: Output standard SVG (default).
    • -d <seconds>: Duration to profile.
    • -i <iterations>: Number of profiles to capture.

    How to use

    Run the script from the host

    # Capture 30s profile in speedscope format (BEST for deep dive)
    ./profile_memmachine.sh -f speedscope -d 30 -i 1
    
    # Capture standard flamegraph
    ./profile_memmachine.sh -f flamegraph
  9. sscargal commented on Jan 6, 2026

    @sscargal
    Contributor

    Episodic Memory + Write Test

    • MemMachine_Workers: 1
    • Memory_Type: Episodic
    • I/O Pattern: Write
    Image

    Here are the raw results:

    Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
    --------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
    POST     /api/v2/memories [add assistant msg]                                             686     0(0.00%) |   1820     204    4878   1700 |    1.36        0.00
    POST     /api/v2/memories [add user msg]                                                  735     0(0.00%) |   8187     186  146657   1800 |    1.46        0.00
    POST     /api/v2/projects [create]                                                        500     0(0.00%) |    221       8     857    220 |    0.99        0.00
    --------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
             Aggregated                                                                      1921     0(0.00%) |   3840       8  146657   1100 |    3.81        0.00
    
    Response time percentiles (approximated)
    Type     Name                                                                                  50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
    --------|--------------------------------------------------------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------
    POST     /api/v2/memories [add assistant msg]                                                 1700   2400   2700   2800   3100   3400   3900   4200   4900   4900   4900    686
    POST     /api/v2/memories [add user msg]                                                      1800   2600   2800   3000   3800  82000  86000 143000 147000 147000 147000    735
    POST     /api/v2/projects [create]                                                             220    250    260    290    380    460    630    790    860    860    860    500
    --------|--------------------------------------------------------------------------------|--------|------|------|------|------|------|------|------|------|------|------|------
             Aggregated                                                                           1100   2100   2500   2700   3100   3800  81000  85000 146000 147000 147000   1921
    

    Here is one of the profile flamegraphs

    Image

    The profiling SVG shows that SCRAM Authentication is a bottleneck. Under load, the default Postgres database connection pool (size 5) is insufficient. This forces the application to create and discard temporary "overflow" connections rapidly. Every new connection setup requires an expensive SCRAM authentication handshake, which saturates the CPU and stalls the server.

    Suggested next step - Increase Pool Size: Add configuration for pool_size and max_overflow to the database config.

  10. sscargal commented on Jan 7, 2026

    @sscargal
    Contributor

    I increased the Postgres DB Connection Pool size pool_size and max_overflow settings from 5 to 20 by default.

    • pool_size is the number of steady DB connections your app keeps open
    • max_overflow is how many extra, temporary connections it may create above that when demand spikes. Tuning them is about matching your expected concurrent DB work to PostgreSQL’s capacity (max_connections, vCPU, memory) without oversubscribing the database.

    This had a significant benefit for users <=100 as shown in the screenshot below. We now achieve a higher and more consistent RPS

    Image

    I will add code to make this user configurable, but keep the defaults to allow users to tune the database

  11. sscargal commented on Jan 15, 2026

    @sscargal
    Contributor

    Testing the fix for #908 in PR #944

    vCPUs: 8
    Memory: 16GiB
    OS: Ubuntu 25.04
    MemMachine Workers: 5

    Test 1 - Episodic + Write-only

    Image

    The results show some improvement in RPS, with peaks up to ~80 RPS.
    Average RPS increased from 23 to 37.5 ~= 62% improvement.
    Performance appears stable up to 250 users; above that, we observe erratic RPS and a significant increase in latency.

  12. github-actions commented on Apr 24, 2026

    @github-actions
    Contributor

    This issue has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs. If this issue is still relevant, please comment to keep it open, or add the keep-open label if it should remain open indefinitely (e.g., a roadmap item awaiting a champion). Thank you for your contributions.

  13. added
    keep-openPrevents the auto-close task from closing this issue.
    and removed on Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

keep-openPrevents the auto-close task from closing this issue.performanceIssues relating to MemMachine performance

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions