ThinkGeo.com    |     Documentation    |     Premium Support

GenerateTileCacheAsync: MaxConcurrency has almost no effect — rendering is serialized by a per-layer lock

Package: ThinkGeo.Core 15.0.0-beta136 (also ThinkGeo.UI.Wpf / Gpu / Gdal / SqlServer / PostgreSql / FileGeoDatabase 15.0.0-beta136)

Platform: .NET 10, WPF, win-x64

API: LayerBase.GenerateTileCacheAsync(...) with TileCacheGenerationOptions.MaxConcurrency

Summary

TileCacheGenerationOptions.MaxConcurrency schedules N tile tasks concurrently, but the

actual layer drawing inside each task is guarded by a semaphore keyed on the layer instance.

Because every tile draws the same shared layer objects, all rendering serializes onto one

worker at a time. Raising MaxConcurrency therefore does not reduce wall-clock time in any

meaningful way, and cache builds do not get faster on machines with more cores.

Observed in production: identical cache builds take essentially the same wall-clock time on a

low-core workstation and on a much higher-core machine. Only single-thread clock speed moves

the number, and only slightly.

Where it happens

LayerBase.GenerateTileCacheAsync enumerates the intersecting cells, then dispatches one task

per tile through the MaxConcurrency limiter. Each task calls the private tile renderer, whose

layer loop is (decompiled from 15.0.0-beta136):


foreach (LayerBase item in layers)

{

    if (!item.IsVisible) continue;

    if (geoCanvas.CancellationToken.IsCancellationRequested) break;

    if (item.ThreadSafe == ThreadSafetyLevel.Unsafe)

    {

        using (await AsyncLocker.LockAsync(item))   // <-- serializes across ALL tile tasks

            await A(geoCanvas, item);              //     layer.Open(); layer.Draw(...)

    }

    else

    {

        await A(geoCanvas, item);

    }

}

AsyncLocker hands out a single-slot semaphore per object:


private static readonly ConcurrentDictionary<object, SemaphoreSlim> kU = new();

SemaphoreSlim orAdd = kU.GetOrAdd(lockObject, _ => new SemaphoreSlim(1, 1));

And LayerBase's constructor opts every layer into the locked path by default:


protected LayerBase()

{

    ThreadSafe = ThreadSafetyLevel.Unsafe;

}

ShapeFileFeatureLayer, FeatureLayer, Layer and InMemoryFeatureLayer do not override this,

so they all run under the lock. Only async/remote types opt into ThreadSafetyLevel.Safe

(ThinkGeoVectorMapsAsyncLayer, RasterXyzTileAsyncLayer, GroupLayer, AdornmentLayer,

ArcGisServerRest*, VectorMbTilesAsyncLayer, WfsV2AsyncLayer, and similar).

Net effect

Since the caller passes one shared collection of layer instances, and drawing dominates the cost

of a tile, the only work that actually runs in parallel is:

  • new SkiaGeoCanvas() / BeginDrawing / EndDrawing

  • geoImage.GetImageBytes() (PNG encode)

  • fileTileCache.SaveTileAsync(...)

Everything that reads geometry and rasterizes it is behind the per-layer mutex. Wall-clock time

is approximately the serialized sum of all tile draws, regardless of MaxConcurrency.

What we would like

The API accepts MaxConcurrency and a single layer collection, which strongly implies the

generator will parallelize rendering. Today the caller cannot get that, because the only escape

hatch is setting ThreadSafe = ThreadSafetyLevel.Safe on layer types whose feature sources are

not in fact thread-safe (shared file handles and cached state on ShapeFileFeatureSource and the

database sources), which would race rather than speed things up.

Options that would resolve it, in our order of preference:

  1. Have the generator clone the layer stack per worker. When MaxConcurrency > 1 and a layer

    reports Unsafe, produce MaxConcurrency independent layer sets (e.g. via CloneDeep) and

    give each worker its own, so the per-instance semaphores no longer collide.

  2. Accept a layer factory. An overload taking Func<IEnumerable<LayerBase>> that the

    generator invokes once per worker would let callers build the per-worker layer sets themselves,

    which is the safest option for sources with credentials or connection state.

  3. At minimum, document the current behavior on TileCacheGenerationOptions.MaxConcurrency

    that it only parallelizes encoding and I/O for Unsafe layers, and that meaningful speedup

    requires per-worker layer instances arranged by the caller.

Reproduction

  1. Build a ShapeFileFeatureLayer (or several) over a reasonably large dataset with a valid .idx.

  2. Call `Layer.GenerateTileCacheAsync(layers, fileTileCache, matrixSet, extent, mapUnit, zoom, zoom,

    progress, scaleFactor, generationOptions: new TileCacheGenerationOptions { MaxConcurrency = 1 },

    cancellationToken: token)` and record wall-clock time.

  3. Repeat with MaxConcurrency = Environment.ProcessorCount on a machine with many cores.

  4. The two runs take approximately the same time; CPU utilization stays near one core for the

    duration of the draw-bound portion.

Hi Julian

You read it right. Every FeatureLayer is ThreadSafetyLevel.Unsafe by default, DrawTileAsync2 locks on the instance, and every tile task shared your one layer collection, so they drew one at a time.

It’s fixed in 15.0.0-beta143, along the lines of your first two options:

  • One layer set per worker. GenerateTileCacheAsync(layers, ...) builds MaxConcurrency sets up front: the first worker draws with your instances, every further worker with a CloneDeep() of each layer that is not thread safe (thread-safe layers are shared). Each tile draws on the thread pool. The clones are closed when the generation ends; your layers are left as they were.

  • A factory overload. GenerateTileCacheAsync(Func<IEnumerable<LayerBase>> layersPerWorker, ...) calls the factory once per worker, for database or service sources whose instances you want to build yourself. The generator opens those and closes them when done.

  • TileCacheGenerationOptions.MaxConcurrency now documents all of this.

One rule: a layer can only be cloned while it is closed, so a layer passed in open stays shared under its lock, as before. Pass closed layers, or use the factory.

Measured on 12 cores, a street shapefile with lines and labels, 4,776 tiles over five zoom levels are as following. Past four workers the encode and the writes start to share the time, so the curve flattens - but it is no longer a no-op.

MaxConcurrency before beta143
1 33 s 33 s
4 ~33 s 14 s
8 ~33 s 13 s

And just FYI tilecache is not needed for the new GPU render, instead you can convert the shapefiles to pmtiles by using VectorPmTilesGenerator, to improve the performance. We’ll talk about it in the HowDoI sample but let me know if you have any questions.

Thanks,
Ben

Thanks Ben, that improved cache build time by a lot!

Regarding your FYI - interesting, how would the best GPU workflow look like for our usecase then (editable layer styles with PMTiles)?

Our map combines shapefiles, database feature layers and rasters. Users frequently change colors, symbols, thematic styles and labels. We retain classic sources for queries, selection and printing.

Currently we:

  • Translate classic styles with FeatureLayerTranslator and compose them with MapStyle.AddFeatureLayer.
  • Generate PMTiles for eligible shapefiles using VectorPmTilesGenerator, preserving attributes and matching the translated source-layer name.
  • Register PmTilesVectorTileSource through GpuBasemap.TileSources, overriding the translated runtime source by ID.
  • Reuse PMTiles files across style changes.

We encountered stale rendering when toggling labels or changing symbols. Switching GPU off and back on displayed the updated styles. As a workaround, we now replace the GPU host for style changes. During editing, we show CPU previews and rebuild GPU after a 10-second idle period.

What is the recommended way to update styles and labels while retaining the GPU host and existing vector tiles? Is our PMTiles source-override approach appropriate, or should we compose the sources and styles differently? Guidance on source ownership and invalidation with SetStyleAsync would be especially helpful.

A HowDoI example covering this workflow would be great - but quick guidance here would also be enough. Thanks!

Hi Julian,

Same shape as the restyle answer in 12446: translate again over translation.Source, hand the map a new MapStyle with SetStyleAsync. It keeps the decoded tiles of every source it already holds and rebuilds only buckets and labels. For a label, SetLayerVisibility(id, …) on the attached style needs no rebuild at all.

Your stale rendering is one specific thing: AddFeatureLayer into a style that already holds that layer appends — it never replaces. Both draw, so a label you removed keeps showing. Build a new MapStyle per change rather than editing the live one, and the old layers go with it. That is why a new host looked like the cure.

PMTiles. GpuBasemap.TileSources is read when the map opens; a source registered there under the translated source id replaces the runtime cutter for that id and survives every SetStyleAsync. Keep its source-layer named exactly as the translation’s SourceLayer and on the same tile grid as your other sources. Reusing the files across style changes is right — styles apply at draw time.

One correction to what we told you before: a restyle no longer refuses when the source count changes — that is the 12455 item, in beta157 onward. Switching a layer on at runtime does not need a new host any more.

Thanks,
Ben