> ## Documentation Index
> Fetch the complete documentation index at: https://docs.solvice.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Performance Optimization Guide

> How problem size, the time budget, and concurrency actually affect solve time and solution quality

# Performance Optimization Guide

The VRP solver's performance is governed by a small number of real levers: the `time_limit_s` budget you set per request, the size and shape of the problem (jobs, vehicles, active constraints), and how many solves run concurrently on the machine serving them. This guide describes those levers as they exist in `POST /v3/routing/solve` today — not a generic tuning framework borrowed from another engine.

<Info>
  This solver runs a single algorithm, [Adaptive Large Neighborhood Search (ALNS)](#how-more-time-becomes-a-better-solution) — there is no `constructionHeuristic` or `localSearchType` switch to choose between different algorithm families, and no `moveThreadCount` to raise per-request parallelism. There is no `matrixId` for persisting a matrix across requests, but there *is* a warm start: `initial_routes` lets a re-optimization begin from the plan you already have. Every option named below is a real field in the current request schema or a real, documented deployment constant.
</Info>

## What Affects Solve Time?

```mermaid theme={null}
mindmap
  root((VRP Performance))
    Problem Size
      Number of jobs
      Number of vehicles
      Number of unique locations
    Active Constraints
      Time windows
      Skills
      Relations / shipments
      Multi-trip reload
    Request Options
      time_limit_s
      seed
    Server Load
      SOLVER_CONCURRENCY
      Queue depth
      Matrix fetch latency
```

* **Problem size** — more jobs means more candidate insertion positions to scan, both in repair and in the periodic inter-route local-search pass; see [Where the Time Actually Goes](#where-the-time-actually-goes). This is the single biggest driver of iteration count for a fixed time budget.
* **Active constraints** — the solver only pays for the constraint types a request actually activates. A plain capacitated problem with no time windows, skills, or relations checks exactly one constraint per candidate position; adding time windows, skills, or relations adds real per-position or per-route cost. See [Constraint System](/guides/vrp/v3/concepts/constraint-system#performance-impact) for the full breakdown.
* **Request options** — `time_limit_s` directly controls how many ALNS iterations run; `seed` doesn't affect speed but does affect which part of the solution space gets explored.
* **Server load** — every request gets exactly one ALNS thread; if the server is already running `SOLVER_CONCURRENCY` concurrent solves, your request queues instead of running immediately. See [Concurrency and Sizing](#concurrency-and-sizing) below.

## How More Time Becomes a Better Solution

The solver runs [Adaptive Large Neighborhood Search (ALNS)](/guides/vrp/v3/concepts/scoring-explanation): construct an initial solution with regret-2 insertion, then repeatedly destroy part of it and repair it, keeping the best result found. This is an **anytime** algorithm — there is no separate "fast mode" or "quality mode" algorithm choice. The only dial is how long it runs:

```mermaid theme={null}
graph LR
    subgraph "Time Budget -> Iterations -> Quality"
        A["500ms -> ~2,500 iter"] --> B["1s -> ~5,000 iter"]
        B --> C["5s -> ~25,000 iter"]
        C --> D["10s -> ~50,000 iter"]
    end
```

More iterations means more destroy/repair/local-search cycles explored, which means a better (or equal) solution — never a worse one, since the solver only keeps the best solution it has found. But the relationship has strong diminishing returns: most of the improvement happens early in the budget, and doubling the budget does not halve the cost.

<Note>
  The figures above use the \~5 iterations/millisecond order of magnitude the solver's own calibration uses for a \~100-job instance. Treat it as an order of magnitude, not a promise: actual throughput depends on job count, vehicle count, and which constraints are active — a problem with driver breaks, many time windows, or multi-trip reloads does far fewer iterations per second than a plain CVRP of the same size, because each iteration does more work. The number that matters for your problem is the one the response reports: compare `summary.iterations` against your `time_limit_s`.
</Note>

## The Real Tuning Knob: `options.runtime.time_limit_s`

```json theme={null}
{
  "options": {
    "runtime": {
      "time_limit_s": 5,
      "seed": 42
    }
  }
}
```

<ParamField body="time_limit_s" type="integer" default="1">
  Wall-clock solve-time budget, in **seconds**. Default `1`, maximum `300` — a larger value is rejected with HTTP 400. This is pure search time: matrix fetch (the Solvice Maps round trip) and any time spent queued behind other concurrent solves are bounded separately and are not deducted from this budget. Source: `RuntimeOptions.time_limit_s`, `src/api/v3_types.rs`.
</ParamField>

<ParamField body="seed" type="integer (u64)">
  Random seed for the ALNS search's pseudorandom number generator. Because the search is bounded by wall-clock time rather than iteration count, the same seed usually reproduces an identical plan on one machine but a materially faster or slower machine can diverge — it is a reproducibility aid, not a determinism guarantee. Different seeds explore different neighborhoods, so trying a handful and keeping the best result is a legitimate quality lever when you have budget to spare. Source: `RuntimeOptions.seed`, `src/api/v3_types.rs`.
</ParamField>

<ParamField body="traffic.departure_time" type="string (RFC 3339)">
  Departure instant for the traffic lookup — plan tomorrow's 08:00 routes against morning-rush travel times instead of traffic at solve time. Note this **bypasses the server-side matrix cache**, so a request that pins a departure time always pays a full matrix fetch. Source: `TrafficOptions.departure_time`, `src/api/v3_types.rs`.
</ParamField>

<Note>
  There is no caller-supplied matrix field: `options.runtime.matrix` was removed as a Phase-1 stub that never shipped, so every solve fetches live from Solvice Maps. See [Distance Matrix Integration](/guides/vrp/v3/concepts/distance-matrices#matrix-related-request-fields) for what that fetch costs and when the cache absorbs it.
</Note>

There is no `constructionHeuristic`, `localSearchType`, `nearbySelection`, or per-request `moveThreadCount` field in the schema. Beyond `time_limit_s`, the one lever that changes *where* the search starts rather than how long it runs is `initial_routes` — a warm start from an existing plan, which is what re-optimization should use rather than re-solving from scratch.

## Problem Size Guidelines

These are rough starting points, not guarantees — actual iteration count for a given time budget depends heavily on which constraints your problem activates (see above). Treat them as a starting point to tune from, not a promise.

<Tabs>
  <Tab title="Small (< 100 jobs)">
    ```json theme={null}
    {
      "options": {
        "runtime": { "time_limit_s": 1 }
      }
    }
    ```

    The default 1-second budget is usually sufficient. Expect thousands of iterations and near-optimal solutions for straightforward capacity/time-window problems at this size.
  </Tab>

  <Tab title="Medium (100-500 jobs)">
    ```json theme={null}
    {
      "options": {
        "runtime": { "time_limit_s": 5 }
      }
    }
    ```

    More candidate positions per repair means fewer iterations per second at the same wall-clock budget, so raise `time_limit_s` to keep total iteration count in a useful range.
  </Tab>

  <Tab title="Large (500-2000 jobs)">
    ```json theme={null}
    {
      "options": {
        "runtime": { "time_limit_s": 10 }
      }
    }
    ```

    At this size, repair's per-iteration cost (scanning all unassigned jobs x all routes x all positions) dominates even more heavily. Expect the solver to still find good, feasible solutions, but each additional second of budget buys proportionally fewer iterations than it would for a smaller problem.
  </Tab>

  <Tab title="Very Large (2000+ jobs)">
    ```json theme={null}
    {
      "options": {
        "runtime": { "time_limit_s": 30 }
      }
    }
    ```

    Above **3,000 jobs** the solver automatically switches to a decomposition pipeline: it clusters the problem geographically, runs a full ALNS solve per cluster, and reassembles across cluster boundaries. This happens server-side with no request field to enable it, and only when the problem qualifies — coordinates present, capacity/time-window constraints only, and a homogeneous fleet at one shared depot. Supplying `initial_routes` also opts out of it, since a warm-started request goes to plain ALNS. Below those conditions, or below 3,000 jobs, the only in-schema lever at this size is more `time_limit_s`.
  </Tab>
</Tabs>

<Tip>
  For problems under 50 jobs, the 1-second default is almost always enough — additional time rarely changes the result. For 100+ jobs, start around 3-5 seconds. Beyond 10 seconds, improvements are typically marginal for any single problem; that time budget is usually better spent running the same problem with 3-5 different `seed` values and keeping the best result, since seeds explore genuinely different neighborhoods rather than just refining the same one further.
</Tip>

## Where the Time Actually Goes

There is no single bottleneck — which phase dominates depends on the shape of your problem. The most recent instruction-level profile (callgrind, 2026-08-30, five benchmark instances) measured:

| Instance shape                | Periodic inter-route local search | Repair (in-loop)        |
| ----------------------------- | --------------------------------- | ----------------------- |
| 150 jobs / 15 vehicles, dense | 45%                               | \~15%                   |
| 300 jobs / 50 vehicles        | 38%                               | 15% (+19% construction) |
| 3 technicians × 5-day week    | 41%                               | \~15%                   |
| Multi-trip with reloads       | 29%                               | **33%**                 |
| Driver breaks                 | 28%                               | 28%                     |

On a well-fed dense instance the **periodic inter-route pass dominates**; repair only takes over where the field-service segment lives — multi-trip reloads, driver breaks, and over-constrained fleets. An older "repair is 80–93% of ALNS time" figure circulated before the production-parity benchmark fixtures existed; it no longer holds and is not a useful planning number.

Two things follow for tuning. First, problem size still drives iteration count more than anything else, because both dominant phases scale with route count × route length. Second, adding constraints your request doesn't need is not free — driver breaks in particular shift the whole cost profile, with route re-timing alone accounting for about a third of the solve.

## Concurrency and Sizing

Solve throughput on a given machine is bounded by CPU cores, not by any per-request setting. Each incoming solve request gets exactly **one ALNS thread** — there is no `moveThreadCount` or similar option to make a single request use more cores. Instead, the server runs multiple *requests* concurrently, up to `SOLVER_CONCURRENCY` (default: `available_parallelism - 1`, reserving one core for the HTTP/async runtime). Requests beyond that concurrency limit queue; once the queue reaches `SOLVER_MAX_WAITING` (default: `2 x SOLVER_CONCURRENCY`), new requests are rejected with HTTP 503 rather than queued indefinitely.

The concurrency permit is acquired **after** the Solvice Maps matrix fetch completes, so a request that's waiting on network I/O for its distance matrix doesn't tie up a CPU permit that another request's ALNS search could be using.

**Sizing guide for a VM**, following directly from the `SOLVER_CONCURRENCY`/`SOLVER_MAX_WAITING` defaults:

| Cores | Concurrent solves | Queue depth | Total capacity |
| ----- | ----------------- | ----------- | -------------- |
| 4     | 3                 | 6           | 9 requests     |
| 8     | 7                 | 14          | 21 requests    |
| 16    | 15                | 30          | 45 requests    |

<Note>
  "Concurrent solves" is `cores - 1` and "queue depth" is `2 x` that number — these follow directly from the `SOLVER_CONCURRENCY`/`SOLVER_MAX_WAITING` defaults, not from a separate sizing formula. If your workload needs more throughput than a given VM size supports, the answer is more cores (or more instances behind a load balancer using `GET /health`'s queue stats as the readiness signal), not a request-level tuning knob — there isn't one for this.
</Note>

If you are self-hosting and see 503s under load, check `GET /health` for `solver_active`/`solver_waiting` before assuming it's a solve-quality problem — it may simply mean the queue is full.

## Distance Matrix Performance

Every solve fetches a real road-network distance/duration matrix from Solvice Maps before the ALNS search starts — there is no selectable Euclidean or Haversine fallback mode, and no `matrixId` persistent-cache field on the request. How that fetch is performed (the sync-vs-async cutover at 150 coordinates, the in-process pairwise cache and its `CACHE_MAX_COORDS`/`CACHE_TTL_SECS` constants, and what `options.runtime.traffic` does to it) is covered in full in [Distance Matrix Integration](/guides/vrp/v3/concepts/distance-matrices) — this page won't duplicate it.

The one fact worth repeating here: matrix fetch time and queue wait are **not** deducted from `time_limit_s`. A slow matrix fetch or a long queue wait shows up as added wall-clock latency on top of your configured time limit, not as a smaller search budget. Source: `RuntimeOptions.time_limit_s`, `src/api/v3_types.rs`.

## Reading Performance From the Response

There is no separate performance-monitoring endpoint or opt-in diagnostics flag. Every `POST /v3/routing/solve` response already carries the real numbers you need in `summary`:

```json theme={null}
{
  "summary": {
    "status": "solved",
    "vehicles_used": 1,
    "jobs_assigned": 2,
    "jobs_unassigned": 0,
    "total_distance_m": 8450,
    "total_duration_s": 1980,
    "estimated_cost": { "total": 16.5, "currency": "EUR", "components": { "travel": 16.5, "waiting": 0.0 } },
    "elapsed_ms": 142,
    "iterations": 5000
  }
}
```

<ParamField body="summary.iterations" type="integer (u64)">
  Number of ALNS iterations completed within the time budget. If this is only a few hundred for a problem with hundreds of jobs, the time budget was likely too tight for the problem size — raise `time_limit_s`. Source: `Summary.iterations`, `src/api/v3_types.rs`.
</ParamField>

<ParamField body="summary.elapsed_ms" type="integer (u64)">
  Wall-clock time taken by the **entire request** in milliseconds — this includes matrix fetch and problem conversion, not just the ALNS search itself, so it will generally be larger than `time_limit_s x 1000`. Source: `Summary.elapsed_ms`, `src/api/v3_types.rs`.
</ParamField>

<ParamField body="summary.status" type="string enum">
  One of `solved` (every job assigned), `partial` (some jobs unassigned), or `infeasible` (no job could be served). Source: `SolveStatus` enum, `src/api/v3_types.rs`.
</ParamField>

There are no `scoreCalculationCount`, `moveEvaluationCount`, or `bestScoreTime` fields anywhere in this schema — those come from a different engine's scoring model and don't apply here. If you need to know whether the search had enough time, `summary.iterations` next to your requested `time_limit_s` is the real signal: compare it against the throughput guidance in [How More Time Becomes a Better Solution](#how-more-time-becomes-a-better-solution) for a problem of similar size.

<Tip>
  If `jobs_unassigned` is non-zero, don't reach for a bigger `time_limit_s` first — check the `unassigned[]` array. Each entry carries a structured `reasons`/`relaxations` payload (see [Constraint System](/guides/vrp/v3/concepts/constraint-system#debugging-why-didnt-my-job-get-placed)) that usually tells you it's a hard constraint (capacity, a time window, a skill mismatch) rather than a search-time problem. More iterations cannot place a job the constraints make infeasible for every vehicle.
</Tip>

## Best Practices

<Check>
  **Performance tuning checklist:**

  1. **Start with the 1-second default.** For problems under \~100 jobs with ordinary constraints, it's almost always enough.
  2. **Raise `time_limit_s`, not a construction/local-search algorithm choice.** There is one algorithm; the only per-request dial is how long it runs.
  3. **Check `summary.iterations` before assuming more time will help.** A low iteration count for your problem size means the budget was tight; a high one that still isn't improving means you've hit diminishing returns.
  4. **Try multiple `seed` values if you have spare time budget**, and keep the best result — this explores different neighborhoods rather than refining the same one longer.
  5. **Diagnose `jobs_unassigned` via `unassigned[].reasons`, not via more time.** A hard-constraint rejection doesn't get fixed by additional ALNS iterations.
  6. **Size your deployment by cores, not by request options.** Each solve gets one ALNS thread; total throughput is `SOLVER_CONCURRENCY` concurrent solves plus a `SOLVER_MAX_WAITING` queue — see the sizing table above.
  7. **Watch `GET /health`** (`solver_active`, `solver_waiting`) if you're seeing HTTP 503s under load — that's a capacity signal, not a solve-quality one.
  8. **Use `initial_routes` for re-optimization instead of re-solving cold.** There is no `matrixId` and no caller-supplied matrix — every solve fetches from Solvice Maps — but starting the search from the plan you already have is both faster to a good answer and far kinder to plan stability.
</Check>

## Related Topics

<CardGroup cols={2}>
  <Card title="Distance Matrices" icon="map" href="/guides/vrp/v3/concepts/distance-matrices">
    How the matrix fetch, sync/async cutover, and pairwise cache work
  </Card>

  <Card title="Constraint System" icon="cog" href="/guides/vrp/v3/concepts/constraint-system">
    Which constraints are cheap (O(1)) versus which scan the route
  </Card>

  <Card title="Solution Quality" icon="chart-line" href="/guides/vrp/v3/concepts/solution-quality">
    Balancing speed against solution quality in more depth
  </Card>

  <Card title="Objective Function" icon="star" href="/guides/vrp/v3/concepts/scoring-explanation">
    How the two-tier objective ranks solutions once they're feasible
  </Card>
</CardGroup>
