From 07b9e562083f5dd0b0fa1fc10fc3aadf58b0fc71 Mon Sep 17 00:00:00 2001 From: Tyler Young Date: Thu, 26 May 2022 12:27:31 -0500 Subject: [PATCH] Halve runtime on large lines This dramatically improves performance on lines with a large number of points, primarily by reducing the number of times we have to iterate over the list. - Removes calls to `length/1`, since it's linear in the size of the list - Cuts down on the number of times we access the last element of a list - Cuts down on the number of new lists we construct - Cuts down on the number of times we iterate the full list to find the maximum distance This also adds a very simple benchmark to the test suite, which can be run with: $ mix test --only bench On my machine (an M1 Mac running Elixir 13.4 + OTP 24, so no JIT support), the included benchmark runs in roughly 0.19 seconds per iteration in the shipping version of the code. With my changes applied, that drops down to roughly 0.08 seconds. --- lib/simplify.ex | 31 +++++++++++++++++++------------ test/simplify_test.exs | 22 ++++++++++++++++++++++ test/test_helper.exs | 2 +- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/lib/simplify.ex b/lib/simplify.ex index bb5d6d2..045e6f8 100644 --- a/lib/simplify.ex +++ b/lib/simplify.ex @@ -6,27 +6,34 @@ defmodule Simplify do simplify_dp_step(coordinates, tolerance * tolerance) end - @spec simplify(%Geo.LineString{}, number) :: %Geo.LineString{} + @spec simplify(Geo.LineString.t(), number) :: Geo.LineString.t() def simplify(%Geo.LineString{} = linestring, tolerance) do %Geo.LineString{coordinates: simplify(linestring.coordinates, tolerance)} end - defp simplify_dp_step(segment, _) when length(segment) < 3, do: segment + defp simplify_dp_step([], _), do: [] + defp simplify_dp_step([_] = segment, _), do: segment + defp simplify_dp_step([_, _] = segment, _), do: segment defp simplify_dp_step(segment, tolerance_squared) do - first = List.first(segment) - last = List.last(segment) + [first | tail] = segment + {last, middle} = List.pop_at(tail, -1) - {far_index, _, far_squared_dist} = - Enum.zip(0..(length(segment) - 1), segment) - |> Enum.drop(1) - |> Enum.drop(-1) - |> Enum.map(fn {i, p} -> {i, p, seg_dist(p, first, last)} end) - |> Enum.max_by(&elem(&1, 2)) + {_, far_value, far_index, far_squared_dist} = + Enum.reduce(middle, {1, nil, 1, 0}, fn element, {idx, max_val, max_idx, max_dist} -> + dist = seg_dist(element, first, last) + + if dist >= max_dist do + {idx + 1, element, idx, dist} + else + {idx + 1, max_val, max_idx, max_dist} + end + end) if far_squared_dist > tolerance_squared do - front = simplify_dp_step(Enum.take(segment, far_index + 1), tolerance_squared) - [_ | back] = simplify_dp_step(Enum.drop(segment, far_index), tolerance_squared) + {pre_split, post_split} = Enum.split(segment, far_index + 1) + front = simplify_dp_step(pre_split, tolerance_squared) + [_ | back] = simplify_dp_step([far_value | post_split], tolerance_squared) front ++ back else diff --git a/test/simplify_test.exs b/test/simplify_test.exs index 5c65a06..1d41347 100644 --- a/test/simplify_test.exs +++ b/test/simplify_test.exs @@ -118,4 +118,26 @@ defmodule SimplifyTest do assert length(simplified.coordinates) < length(ring.coordinates) assert length(simplified.coordinates) == length(simplified_again.coordinates) end + + @tag bench: true + test "benchmark large ring simplification" do + ring = + Path.join(["test", "fixtures", "large_linestring.wkt"]) + |> File.read!() + |> Geo.WKT.decode!() + + runs = 10 + + {usec, _} = + :timer.tc(fn -> + for _ <- 1..runs do + _ = Simplify.simplify(ring, 1) + _ = Simplify.simplify(ring, 10) + _ = Simplify.simplify(ring, 100) + end + end) + + avg_runtime_sec = Float.round(usec / 1_000_000 / runs, 4) + IO.puts("Avg runtime: #{avg_runtime_sec} seconds per run") + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..f7ca292 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1 @@ -ExUnit.start() +ExUnit.start(exclude: [:bench])