Beating SQL Server Parallelism With C# Multithreading: The Geocoding Benchmark
I want to give you a specific benchmark. Not vague "significantly faster" marketing language — an actual result from a real workload that I measured, on identical hardware, and can explain mechanically. Here it is: geocoding 23 years of NOAA storm event radar data, C# with Parallel.ForEach beat SQL Server's parallelism (MAXDOP 8 on an 8-core machine) by roughly 3.5× on the same server. This post explains why.
The Workload
NOAA's storm event data goes back to 1950. The radar data file — spatial points from weather radar, one row per radar return, 23 years of coverage — was approximately 400 million rows. Each row needed to be geocoded: matched to a county FIPS code based on its lat/long coordinates, using a spatial lookup against county polygon boundaries.
The geocoding operation was computationally intensive (point-in-polygon test against irregular county boundaries) and embarrassingly parallel (each row could be processed independently).
The SQL Server Approach: What It Looked Like
The initial approach was a stored procedure with a spatial join using SQL Server's geography data types:
-- Geocoding via SQL Server spatial query
UPDATE RadarData
SET CountyFips = c.FIPS
FROM dbo.RadarData AS r
INNER JOIN dbo.CountyBoundaries AS c
ON c.BoundaryGeog.STContains(geography::Point(r.Latitude, r.Longitude, 4326)) = 1
WHERE r.CountyFips IS NULL;
-- With DOP hint to use all cores
OPTION (MAXDOP 8);SQL Server executed this with parallelism — multiple threads working through the radar data in parallel, each thread running spatial STContains tests against the county boundaries. On the test server (8-core Xeon, 64GB RAM, Premium Storage), this ran at roughly 180,000 rows per minute.
The C# Approach: Parallel.ForEach
The C# version loaded the county boundaries into memory (a list of GeoJSON polygons with their FIPS codes) and processed radar rows in parallel using Parallel.ForEach:
// Load county boundaries once into memory
var countyBoundaries = LoadCountyBoundaries(); // returns List<(Polygon, string FipsCode)>
// Process radar data in parallel batches
Parallel.ForEach(
radarDataReader.ReadBatches(batchSize: 10000),
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
batch =>
{
var results = new List<GeocodedRow>();
foreach (var row in batch)
{
var point = new GeoPoint(row.Latitude, row.Longitude);
var match = countyBoundaries
.FirstOrDefault(cb => cb.Polygon.Contains(point));
results.Add(new GeocodedRow
{
RadarId = row.Id,
CountyFips = match?.FipsCode
});
}
// Bulk insert results batch
BulkInsertResults(results, destinationConnection);
});
This version ran at roughly 630,000 rows per minute — the same server, same data, 3.5× faster.
Why C# Won
Several factors combined:
Memory access patterns. The C# version loaded all county boundaries into process memory once and kept them there for the duration of the run. Each worker thread had direct memory access to the boundary data. SQL Server's spatial index was also in memory (buffer pool), but the buffer pool management overhead — locking, page-level access, the query execution engine's overhead per operation — added latency that a direct memory array didn't have.
Parallelism overhead in SQL Server. SQL Server's parallelism model has significant coordination overhead: the exchange operators that distribute work between parallel threads, the work table constructs for parallel aggregation, and the thread synchronization at segment boundaries. For a pure CPU-bound computation like point-in-polygon testing, this overhead was measurable. C#'s Parallel.ForEach had much lower synchronization overhead for the same level of parallelism.
Batch-level control. The C# version processed 10,000 rows per batch and bulk-inserted the results. SQL Server's parallelism processed rows at the plan operator level with smaller internal batches — more round-trips, more I/O synchronization.
No locking overhead. The C# version read from a source table and wrote to a destination table. The SQL Server UPDATE approach needed to lock the rows it was updating, which added lock management overhead that the C# version didn't have.
The Caveat
This result is specific to this workload. SQL Server's parallelism is better than C# parallel code for set-based operations that benefit from SQL Server's query optimizer — complex joins, aggregations with intermediate spills, queries that can benefit from index seeks and sort-merge joins. The point-in-polygon geocoding was a CPU-bound computation that didn't benefit from any of those. Match the tool to the workload. In this case, the right tool was C#. As always, I'm here to help.