From ed76dc69f0d25f07a388a0e7086bb75924a24814 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Wed, 10 Jun 2026 22:50:09 +1000 Subject: [PATCH 1/2] test: add feature-layer CPU benchmark harness Widget- and kernel-level CPU benchmarks for the polyline, polygon, and marker layers, plus a direct getOffsetsXY benchmark. Lives in benchmark/ (not test/) so it is opt-in and does not extend CI runtime: flutter test benchmark/feature_layer_benchmark_test.dart Numbers are JIT and only meaningful relative to each other (before vs after a change on the same machine). --- benchmark/feature_layer_benchmark_test.dart | 277 ++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 benchmark/feature_layer_benchmark_test.dart diff --git a/benchmark/feature_layer_benchmark_test.dart b/benchmark/feature_layer_benchmark_test.dart new file mode 100644 index 000000000..a7f9ab2b8 --- /dev/null +++ b/benchmark/feature_layer_benchmark_test.dart @@ -0,0 +1,277 @@ +// Layer-level CPU benchmarks, used to validate performance work. +// +// Run with: +// flutter test benchmark/feature_layer_benchmark_test.dart --plain-name=benchmark -r expanded +// +// Numbers are JIT/debug-mode and only meaningful *relative* to each other +// (before/after a change on the same machine). Each scenario warms up, then +// times repeated pumps while the camera pans, and reports the best repetition +// (min) to reduce GC/scheduling noise. +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/misc/offsets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +const _center = LatLng(-37.8136, 144.9631); + +List _randomWalk( + math.Random rng, + LatLng start, + int count, [ + double stepDeg = 0.0004, +]) { + final points = []; + var lat = start.latitude; + var lng = start.longitude; + for (var i = 0; i < count; i++) { + lat += (rng.nextDouble() - 0.5) * stepDeg; + lng += (rng.nextDouble() - 0.5) * stepDeg; + points.add(LatLng(lat, lng)); + } + return points; +} + +LatLng _randomNear(math.Random rng, LatLng base, double spreadDeg) => LatLng( + base.latitude + (rng.nextDouble() - 0.5) * spreadDeg, + base.longitude + (rng.nextDouble() - 0.5) * spreadDeg, + ); + +Widget _app(MapController controller, double zoom, List layers) => + MaterialApp( + home: FlutterMap( + mapController: controller, + options: MapOptions(initialCenter: _center, initialZoom: zoom), + children: layers, + ), + ); + +/// Pans the camera back and forth and reports the best-rep average frame +/// build+paint time, in microseconds. +Future _benchPans( + WidgetTester tester, + MapController controller, + double zoom, { + int reps = 3, + int framesPerRep = 40, +}) async { + // Warm-up (fills projection/simplification caches, JIT). + for (var i = 0; i < 10; i++) { + controller.move( + LatLng(_center.latitude, _center.longitude + 0.00001 * (i + 1)), + zoom, + ); + await tester.pump(); + } + + var best = double.infinity; + for (var rep = 0; rep < reps; rep++) { + final sw = Stopwatch()..start(); + for (var i = 0; i < framesPerRep; i++) { + // Small alternating pan, never repeating the previous camera. + controller.move( + LatLng( + _center.latitude + 0.0001 * (i % 7), + _center.longitude + 0.0001 * (i % 11) + 0.000001 * i, + ), + zoom, + ); + await tester.pump(); + } + sw.stop(); + final perFrame = sw.elapsedMicroseconds / framesPerRep; + if (perFrame < best) best = perFrame; + } + return best; +} + +void main() { + testWidgets('benchmark: polylines pan (all visible)', (tester) async { + final rng = math.Random(42); + final polylines = [ + for (var i = 0; i < 600; i++) + Polyline( + points: _randomWalk(rng, _randomNear(rng, _center, 0.02), 60), + strokeWidth: 2, + color: Colors.blue, + ), + ]; + final controller = MapController(); + // Zoom 13: ~0.05° viewport, the 0.02° spread keeps everything visible. + await tester.pumpWidget( + _app(controller, 13, [PolylineLayer(polylines: polylines)]), + ); + final us = await _benchPans(tester, controller, 13); + debugPrint('RESULT polylines_pan_all_visible: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: polylines pan (mostly culled)', (tester) async { + final rng = math.Random(42); + final polylines = [ + for (var i = 0; i < 600; i++) + Polyline( + points: _randomWalk(rng, _randomNear(rng, _center, 0.5), 60), + strokeWidth: 2, + color: Colors.blue, + ), + ]; + final controller = MapController(); + // Zoom 16: viewport much smaller than the 0.5° spread. + await tester.pumpWidget( + _app(controller, 16, [PolylineLayer(polylines: polylines)]), + ); + final us = await _benchPans(tester, controller, 16); + debugPrint('RESULT polylines_pan_mostly_culled: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: polygons with holes pan', (tester) async { + final rng = math.Random(7); + final polygons = [ + for (var i = 0; i < 200; i++) + () { + final base = _randomNear(rng, _center, 0.02); + return Polygon( + points: _randomWalk(rng, base, 40), + holePointsList: [ + for (var h = 0; h < 3; h++) + _randomWalk(rng, _randomNear(rng, base, 0.001), 20, 0.0001), + ], + color: Colors.green.withValues(alpha: 0.5), + ); + }(), + ]; + final controller = MapController(); + await tester.pumpWidget( + _app(controller, 13, [PolygonLayer(polygons: polygons)]), + ); + final us = await _benchPans(tester, controller, 13); + debugPrint('RESULT polygons_holes_pan: ${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: markers pan', (tester) async { + final rng = math.Random(3); + final markers = [ + for (var i = 0; i < 3000; i++) + Marker( + point: _randomNear(rng, _center, 0.05), + width: 20, + height: 20, + child: const SizedBox.shrink(), + ), + ]; + final controller = MapController(); + await tester.pumpWidget( + _app(controller, 14, [MarkerLayer(markers: markers)]), + ); + final us = await _benchPans(tester, controller, 14); + debugPrint('RESULT markers_pan: ${us.toStringAsFixed(0)} us/frame'); + }); + + testWidgets('benchmark: markers pan (mostly culled)', (tester) async { + final rng = math.Random(3); + final markers = [ + for (var i = 0; i < 10000; i++) + Marker( + point: _randomNear(rng, _center, 1), + width: 20, + height: 20, + child: const SizedBox.shrink(), + ), + ]; + final controller = MapController(); + // Zoom 16: viewport much smaller than the 1° spread, so per-frame cost is + // dominated by the per-marker projection + cull check. + await tester.pumpWidget( + _app(controller, 16, [MarkerLayer(markers: markers)]), + ); + final us = await _benchPans(tester, controller, 16); + debugPrint('RESULT markers_pan_mostly_culled: ' + '${us.toStringAsFixed(0)} us/frame'); + }); + + test('benchmark: marker projection kernel', () { + final rng = math.Random(5); + final camera = MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: 14, + rotation: 0, + nonRotatedSize: const Size(800, 600), + ); + const crs = Epsg3857(); + final points = [ + for (var i = 0; i < 10000; i++) _randomNear(rng, _center, 1), + ]; + final projected = [for (final p in points) crs.projection.project(p)]; + const frames = 100; + + // Old per-frame path: full LatLng -> screen projection (trigonometry). + var sink = 0.0; + var sw = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + for (final p in points) { + sink += camera.projectAtZoom(p).dx; + } + } + sw.stop(); + final oldNs = sw.elapsedMicroseconds * 1000 / (frames * points.length); + + // New per-frame path: linear transform of the cached projection. + final zoomScale = crs.scale(camera.zoom); + sw = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + for (final p in projected) { + final (x, _) = crs.transform(p.dx, p.dy, zoomScale); + sink += x; + } + } + sw.stop(); + final newNs = sw.elapsedMicroseconds * 1000 / (frames * points.length); + + debugPrint('RESULT marker_projection_kernel: sink=${sink.isFinite} ' + 'full=${oldNs.toStringAsFixed(1)} ns/marker ' + 'cached=${newNs.toStringAsFixed(1)} ns/marker'); + }); + + test('benchmark: getOffsetsXY holed polygon (direct)', () { + final rng = math.Random(11); + final camera = MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: 14, + rotation: 0, + nonRotatedSize: const Size(800, 600), + ); + const projection = SphericalMercator(); + final points = projection.projectList(_randomWalk(rng, _center, 500)); + final holePoints = [ + for (var h = 0; h < 10; h++) + projection.projectList( + _randomWalk(rng, _randomNear(rng, _center, 0.005), 200, 0.0001), + ), + ]; + + final helper = OffsetHelper(camera: camera); + // Warm-up. + for (var i = 0; i < 20; i++) { + helper.getOffsetsXY(points: points, holePoints: holePoints); + } + var best = double.infinity; + for (var rep = 0; rep < 5; rep++) { + const n = 200; + final sw = Stopwatch()..start(); + for (var i = 0; i < n; i++) { + helper.getOffsetsXY(points: points, holePoints: holePoints); + } + sw.stop(); + final per = sw.elapsedMicroseconds / n; + if (per < best) best = per; + } + debugPrint('RESULT get_offsets_xy_holed: ' + '${best.toStringAsFixed(1)} us/call'); + }); +} From 2cef81bf459697096195afdf743ffb1858adfd66 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Wed, 10 Jun 2026 22:50:28 +1000 Subject: [PATCH 2/2] perf: avoid quadratic hole-point iteration in offset generation & triangulation points.followedBy(holePoints.expand(...)) indexed with per-element elementAt() walks the lazy iterable from the start on every call, making OffsetHelper.getOffsetsXY and the Earcut coordinate flattening O(n^2) in the total point count - on every frame. Flatten into a fixed-length list instead. Benchmark (benchmark/feature_layer_benchmark_test.dart, JIT): getOffsetsXY, 500-point polygon + 10 holes x 200 points: 26,761 us/call -> 33 us/call (~800x) --- .../layer/polygon_layer/polygon_layer.dart | 31 ++++++++++++------- lib/src/misc/offsets.dart | 29 +++++++++++++---- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/lib/src/layer/polygon_layer/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index 7a09c92a9..63e61e922 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -228,19 +228,28 @@ class _PolygonLayerState extends State> (i) { final culledPolygon = culled[i]; - final points = culledPolygon.holePoints.isEmpty - ? culledPolygon.points - : culledPolygon.points - .followedBy(culledPolygon.holePoints.expand((e) => e)); + // Flatten the points and hole points into the coordinate list + // directly, avoiding quadratic `elementAt` calls on a lazy + // concatenated iterable. + var totalLength = culledPolygon.points.length; + for (final hole in culledPolygon.holePoints) { + totalLength += hole.length; + } + final coords = Float64List(totalLength * 2); + var ii = 0; + for (final point in culledPolygon.points) { + coords[ii++] = point.dx; + coords[ii++] = point.dy; + } + for (final hole in culledPolygon.holePoints) { + for (final point in hole) { + coords[ii++] = point.dx; + coords[ii++] = point.dy; + } + } return Earcut.triangulateRaw( - List.generate( - points.length * 2, - (ii) => ii.isEven - ? points.elementAt(ii ~/ 2).dx - : points.elementAt(ii ~/ 2).dy, - growable: false, - ), + coords, holeIndices: culledPolygon.holePoints.isEmpty ? null : _generateHolesIndices(culledPolygon) diff --git a/lib/src/misc/offsets.dart b/lib/src/misc/offsets.dart index 7f679ec6d..3ffbf364b 100644 --- a/lib/src/misc/offsets.dart +++ b/lib/src/misc/offsets.dart @@ -69,9 +69,26 @@ class OffsetHelper { final crs = camera.crs; final zoomScale = crs.scale(camera.zoom); - final realPoints = holePoints == null || holePoints.isEmpty - ? points - : points.followedBy(holePoints.expand((e) => e)); + // Flatten the points and hole points into a single list, so that the + // loops below can use constant-time indexing. Using a lazy concatenated + // iterable here makes `elementAt` walk from the start on every call, + // which is quadratic in the total number of points. + final List realPoints; + if (holePoints == null || holePoints.isEmpty) { + realPoints = points; + } else { + var totalLength = points.length; + for (final hole in holePoints) { + totalLength += hole.length; + } + realPoints = List.filled(totalLength, Offset.zero) + ..setRange(0, points.length, points); + var start = points.length; + for (final hole in holePoints) { + realPoints.setRange(start, start + hole.length, hole); + start += hole.length; + } + } final ox = -_origin.dx; final oy = -_origin.dy; @@ -92,7 +109,7 @@ class OffsetHelper { -worldWidth, ]; final halfScreenWidth = camera.size.width / 2; - final p = realPoints.elementAt(0); + final p = realPoints[0]; late double result; late double bestX; for (int i = 0; i < addedWidths.length; i++) { @@ -119,7 +136,7 @@ class OffsetHelper { if (crs case final CrsWithStaticTransformation crs) { final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { - final p = realPoints.elementAt(i); + final p = realPoints[i]; final (x, y) = crs.transform(p.dx + addedWorldWidth, p.dy, zoomScale); v[i] = Offset(x + ox + shift, y + oy); } @@ -128,7 +145,7 @@ class OffsetHelper { final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { - final p = realPoints.elementAt(i); + final p = realPoints[i]; final (x, y) = crs.transform(p.dx + addedWorldWidth, p.dy, zoomScale); v[i] = Offset(x + ox + shift, y + oy); }