Benchmarking Async vs Threaded Tile Fetch
A team benchmarks tile prefetch, finds asyncio twice as fast as a thread pool, and rewrites the service. In production the difference is about four per cent, because the tile server caps each client at 32 connections and the benchmark ran against a local test server with no limit at all.
Root Cause and Operational Impact
Tile and COG range fetching is the one geospatial workload where asyncio has a genuine structural advantage, and it is also the workload where benchmarks most often measure something other than the client. Throughput here is latency-bound rather than bandwidth-bound, so what is really being measured is how many requests can be in flight — and that number is capped by whichever of the client, the server, or the link runs out first.
Below about 40 concurrent requests the two models are indistinguishable, because neither is anywhere near its own ceiling and both are simply waiting. Above it the thread pool turns over as context switching and stack memory start to cost more than the overlap gains, while the coroutine client keeps climbing until it meets the server’s connection limit.
That last clause is the one that decides whether the rewrite is worth doing. If the tile server caps a client at 32 connections, the crossover never arrives and both models sit in the region where they are equivalent.
Memory is the second axis, and on a forward node it is often the deciding one. Four hundred coroutines cost about 120 MB; four hundred threads cost over a gigabyte, because each carries a stack and — on this workload — frequently an open dataset handle too. On a workstation this is invisible. On the device that actually runs the prefetch it determines whether the thread pool is available at all.
Tiered Resolution Strategy
- Measure the server’s connection limit first (definitive). It is usually the real ceiling, and knowing it tells you immediately whether the models will differ at all.
- Benchmark on the link the node actually has. Latency sets the crossover; a result from a 20 ms office link says nothing about a 180 ms satellite one.
- Keep decode out of the measurement, then add it back deliberately. Decoding in the same process reintroduces the global interpreter lock and flattens both curves, which is a real effect and a different experiment.
- Reuse connections, and say whether you did (safe default). Without pooling, every request pays a TLS handshake, which on a slow link dominates the result and makes both models look identical.
- Report memory alongside throughput. A model that is faster and does not fit on the node has not won.
Production Python Implementation
from __future__ import annotations
import asyncio
import logging
import resource
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, asdict
import httpx
logger = logging.getLogger("incidentgis.fetch_benchmark")
@dataclass
class FetchResult:
model: str
concurrency: int
tiles_per_second: float
peak_rss_mb: float
rtt_ms: float
server_conn_limit: int | None
connection_reuse: bool
decoded_in_process: bool
def _peak_rss_mb() -> float:
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0
async def bench_async(urls: list[str], concurrency: int, *, reuse: bool) -> float:
"""Coroutine fetch. Concurrency is bounded by a semaphore, not by the pool."""
limits = httpx.Limits(
max_connections=concurrency,
max_keepalive_connections=concurrency if reuse else 0,
)
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(limits=limits, timeout=30.0) as client:
async def one(url: str) -> None:
async with sem:
resp = await client.get(url)
resp.raise_for_status()
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
for u in urls:
tg.create_task(one(u))
return len(urls) / (time.perf_counter() - start)
def bench_threads(urls: list[str], concurrency: int, *, reuse: bool) -> float:
"""Thread-pool fetch with a shared session, so pooling is comparable."""
client = httpx.Client(
limits=httpx.Limits(
max_connections=concurrency,
max_keepalive_connections=concurrency if reuse else 0,
),
timeout=30.0,
)
try:
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=concurrency) as pool:
for _ in pool.map(lambda u: client.get(u).raise_for_status(), urls):
pass
return len(urls) / (time.perf_counter() - start)
finally:
client.close()
def measure_server_limit(url: str, ceiling: int = 512) -> int | None:
"""Find where the server stops accepting more concurrent connections.
This is usually the real ceiling, so establishing it first tells you
whether the two models can differ at all on this deployment.
"""
lo, hi = 1, ceiling
accepted = None
while lo <= hi:
mid = (lo + hi) // 2
try:
rate = asyncio.run(bench_async([url] * mid, mid, reuse=True))
except (httpx.HTTPError, OSError):
hi = mid - 1
continue
accepted, lo = mid, mid + 1
logger.debug("server_accepted_concurrency", extra={"n": mid, "rate": rate})
return accepted
Validation Checklist
- The server’s concurrent-connection limit is measured and reported.
- Round-trip latency is stated and matches the deployment target’s link.
- Connection reuse is enabled for both models, or disabled for both, and stated.
- Decoding is excluded from the primary measurement and reported separately.
- Peak resident memory is reported alongside throughput.
- Both models use the same client library so the comparison is of concurrency, not of HTTP stacks.
- The result is a curve against concurrency, not a single number.
- The chosen concurrency is below the server’s limit in production configuration.
Edge Cases and Gotchas
- A test server with no connection limit. The most common way this benchmark misleads. Measure against something configured like production, or state loudly that the ceiling is artificial.
- Different HTTP libraries per model. Comparing
aiohttpagainstrequestsmeasures two HTTP stacks, not two concurrency models. Use one library in both modes. - Warm caches on the server. The second run fetches from the server’s page cache and is not the same experiment as the first. Randomise the tile set or accept that you are measuring the warm case.
- Ignoring the memory axis. A thread pool that reaches a good number at 400 workers and needs a gigabyte has not won on a node with 1.2 GB total.
- Benchmarking prefetch in isolation. In the real service the fetch competes with decode, reprojection, and the database, exactly as the concurrency split describes. An isolated number is an upper bound.
Frequently Asked Questions
Is asyncio actually faster than threads for fetching tiles? Above a crossover, yes, and below it the two are indistinguishable. On a link with 180 milliseconds of round-trip latency both models rise steeply while concurrency is low, because throughput is latency-bound and each extra in-flight request is nearly free. The thread pool peaks near 64 workers at roughly 340 tiles per second and then declines as context switching and stack memory outweigh the overlap; the coroutine client keeps climbing to around 900 tiles per second at 400 in-flight requests. The crossover sits near 40 concurrent requests, and below it the simpler model wins on maintenance rather than losing on speed.
Why do tile-fetch benchmarks so often fail to transfer to production? Because the ceiling in production is usually the server, not the client. A benchmark run against a local test server with no connection limit measures how many requests the client can keep in flight; a production tile server capping each client at 32 connections means neither model ever reaches the region where they differ. Measuring the server’s limit first turns a two-hour benchmark into a five-minute answer, because if the cap is below the crossover the rewrite cannot pay for itself no matter what the curve looks like.
Does memory matter in this comparison? On a forward node it frequently decides it. Four hundred concurrent coroutines cost about 120 megabytes because each in-flight request is a coroutine and a buffer, while four hundred threads cost over a gigabyte because each carries its own stack and, on a geospatial workload, often an open dataset handle as well. Against a 1.2 gigabyte device budget the thread pool becomes unavailable somewhere past 380 workers and the coroutine client never approaches it. On a workstation the difference is invisible, which is exactly why it is missed.
Related
- Async vs Threaded Python for Geospatial I/O — the split this benchmark is meant to inform, and why decode belongs elsewhere.
- Serving Hazard Rasters Over Range Requests — the request-count reasoning that makes latency, not bandwidth, the binding constraint.
- Benchmarking Dockerized GIS Throughput Under Surge Load — the same insistence on stating the conditions that move the result.
- Pre-Staging Vector Tiles Before a Forecasted Landfall — the bulk-seeding job this concurrency choice is usually made for.
Up: Async vs Threaded Python for Geospatial I/O