diff --git a/Makefile b/Makefile index 1f73645..c0b05b1 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,32 @@ all: - @echo "dev_install - Install development dependencies" - @echo "test - Run tests" - @echo "typecheck - Run static type checking only python>=3.10" - @echo "lint - Run static analysis for common problems" - @echo "autoformat - Format all code to a consistent style" - @echo "coverage - Run tests and check the test coverage" - @echo "benchmark - Run a small suite to check performance" - @echo "ci_install - Install dependencies needed for CI" - @echo "fixed_install - Install as a non-editible package like production consumers" - @echo "clean - Delete generated files" - @echo "dist - Build distribution artifacts" - @echo "release - Build distribution and release to PyPI." + @echo "dev_install - Install development dependencies" + @echo "test - Run tests" + @echo "typecheck - Run static type checking only python>=3.10" + @echo "lint - Run static analysis for common problems" + @echo "autoformat - Format all code to a consistent style" + @echo "coverage - Run tests and check the test coverage" + @echo "benchmark - Run a small suite to check performance" + @echo "test_with_floats - Run tests: including the ones with floating point inputs" + @echo "coverage_with_floats - Check test coverage: including floating point input examples" + @echo "ci_install - Install dependencies needed for CI" + @echo "fixed_install - Install as a non-editible package like production consumers" + @echo "clean - Delete generated files" + @echo "dist - Build distribution artifacts" + @echo "release - Build distribution and release to PyPI." test: - python -m pytest + python -m pytest -m 'not benchmark' -m 'not floats' + +test_with_floats: + python -m pytest -m 'not benchmark' benchmark: - python -m pytest tests/performance* + python -m pytest -m benchmark tests/performance* coverage: + python -m pytest --cov=tuplesumfilter -m 'not benchmark' -m 'not floats' tests --cov-fail-under 90 + +coverage_with_floats: python -m pytest --cov=tuplesumfilter tests --cov-fail-under 90 typecheck: @@ -54,4 +62,4 @@ dist: clean release: dist twine upload dist/*.* -.PHONY: all autoformat benchmark ci_install clean coverage dev_install dist fixed_install test typecheck \ No newline at end of file +.PHONY: all autoformat benchmark ci_install clean coverage coverage_with_floats dev_install dist fixed_install test test_with_floats typecheck \ No newline at end of file diff --git a/NotesLog.md b/NotesLog.md index c33f50c..ae40c7e 100644 --- a/NotesLog.md +++ b/NotesLog.md @@ -49,7 +49,6 @@ eugh that it pretty bad ~0.4 for the triplets version $ make benchmark tests/performance_check.py .. [100%] - ------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------ Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -57,3 +56,123 @@ test_input1_pairs 5.4665 (1.0) 6.2297 (1.0) 5.6687 (1.0) test_input1_triplets 384.6154 (70.36) 386.5000 (62.04) 385.4776 (68.00) 0.8287 (8.14) 385.4333 (68.13) 1.5047 (11.67) 2;0 2.5942 (0.01) 5 1 --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ``` + +## if you want to go faster + +We're going to have to break apart the itertools into the underlying nested loops +before we can mess with the aglo. + +but 1st lets split out the float-y bits of the tests, because i think we might want to +make it easier to stop supporting floats and lose those tests. + +wow, even just replacing the `math.isclose` in favor of simple `==` +gets us a ~30% speed up on the (`int` only) benchmarks. + +```sh +$ make benchmark +tests/performance_check.py .. [100%] + +------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------ +Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +test_input1_pairs 2.8727 (1.0) 4.2386 (1.0) 3.1265 (1.0) 0.1638 (1.0) 3.1067 (1.0) 0.1888 (1.0) 78;9 319.8414 (1.0) 326 1 +test_input1_triplets 211.6325 (73.67) 213.3950 (50.35) 212.4042 (67.94) 0.6555 (4.00) 212.2717 (68.33) 0.8081 (4.28) 2;0 4.7080 (0.01) 5 1 +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +Legend: + Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile. + OPS: Operations Per Second, computed as 1 / Mean +=========================================================== 2 passed in 3.59s ============================================================ +``` + +but this does make some of our floaty tests fail. + +## simple nested loops for pairwise + +looks like we're seeing an perf improvement over itertools just by breaking out into loops for the pairs +roughly the same speedup we got by dropping float support, but we're back using `math.isclose` + +```sh +tests/performance_check.py .. [100%] + +------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------ +Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +test_input1_pairs 3.0859 (1.0) 6.3263 (1.0) 3.4614 (1.0) 0.2696 (1.0) 3.4232 (1.0) 0.2591 (1.0) 44;6 288.8998 (1.0) 298 1 +test_input1_triplets 392.5019 (127.19) 394.4756 (62.36) 393.5971 (113.71) 0.8023 (2.98) 393.7625 (115.03) 1.2974 (5.01) 2;0 2.5407 (0.01) 5 1 +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +Legend: + Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile. + OPS: Operations Per Second, computed as 1 / Mean +=================================================================================== 2 passed in 4.87s =================================================================================== +``` + +and now we've moved the triplets to nested loops too + +```sh +tests/performance_check.py .. [100%] + + +------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------ +Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +test_input1_pairs 4.6867 (1.0) 9.1275 (1.0) 5.5921 (1.0) 0.8257 (1.0) 5.3198 (1.0) 0.8444 (1.0) 23;12 178.8246 (1.0) 193 1 +test_input1_triplets 371.6804 (79.31) 376.8461 (41.29) 374.2332 (66.92) 2.3729 (2.87) 373.5280 (70.21) 4.3788 (5.19) 3;0 2.6721 (0.01) 5 1 +--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +Legend: + Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile. + OPS: Operations Per Second, computed as 1 / Mean +======================================================================================================= 2 passed in 4.81s ======================================================================================================== +``` + +### trading space for speed + +its relatively common to speed algos up by trading off some memory +for reduced CPU tome complexity. + +I reckon we can do that here using something to keep track of values we've already seen +it'll need to be a fast, $O{1}$, lookup thing: so a `set` or `dict`. + +However, we'll need to ditch `float` support because they don't hash. + +Okaaaay + +That gives us a big speedup in the pair version: note that we are now measuring _micro_ not _milli_ seconds + +```sh +tests/performance_check.py .. [100%] + + +-------------------------------------------------------------------------------------------------- benchmark: 2 tests -------------------------------------------------------------------------------------------------- +Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ +test_input1_pairs 22.1710 (1.0) 152.1860 (1.0) 23.5753 (1.0) 5.5831 (1.0) 22.8580 (1.0) 0.4057 (1.0) 114;461 42,417.2354 (1.0) 7671 1 +test_input1_triplets 176,173.4430 (>1000.0) 188,509.7390 (>1000.0) 184,815.9523 (>1000.0) 4,741.3658 (849.24) 186,896.7185 (>1000.0) 5,253.1090 (>1000.0) 1;0 5.4108 (0.00) 6 1 +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ +``` + +that is > a 200x (nearly 250x) speedup for the pairs. + + +I'll split the float test out further using `pytest` markers and fix up the makefile. + +Now lets do stuff for the triplet version + + +```sh +$ make benchmark +tests/performance_check.py .. [100%] + +-------------------------------------------------------------------------------------------- benchmark: 2 tests -------------------------------------------------------------------------------------------- +Name (time in us) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ +test_input1_pairs 22.1660 (1.0) 166.3790 (1.0) 23.6089 (1.0) 5.0170 (1.0) 23.0000 (1.0) 0.5123 (1.0) 87;521 42,356.8183 (1.0) 7677 1 +test_input1_triplets 1,994.8000 (89.99) 3,561.1120 (21.40) 2,152.1272 (91.16) 204.1428 (40.69) 2,033.1040 (88.40) 299.1878 (584.07) 41;4 464.6565 (0.01) 341 1 +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ +``` + +again note the unit changer for the triplets, +even though we're still (i think $O{n^2}$, down from $O{n^3}$) +we are seeing an approx 175x speedup in the triplets diff --git a/pyproject.toml b/pyproject.toml index d4cf7d4..bac908c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,9 @@ source = ["src", ".tox/*/site-packages"] [tool.coverage.report] show_missing = true + +[tool.pytest.ini_options] +markers = [ + "benchmark: marks tests as for performance benchmarking: they are slow (deselect with '-m \"not benchmark\"')", + "floats: marks tests as for testing floating point support: some algorithms don't work for floats (deselect with '-m \"not float\"')", +] diff --git a/src/tuplesumfilter/sum_to_target.py b/src/tuplesumfilter/sum_to_target.py index 2e740d2..312a7a0 100644 --- a/src/tuplesumfilter/sum_to_target.py +++ b/src/tuplesumfilter/sum_to_target.py @@ -1,6 +1,3 @@ -import itertools -import math - import tuplesumfilter.types as t from tuplesumfilter.app_logging import get_logger @@ -14,12 +11,16 @@ def pairs_that_sum_to(numbers: t.Sequence[t.Num], sum_target: t.Num) -> t.PairsO sum_kind="pairs", sum_target=sum_target, len_input=len(numbers), - algo="itertools", + algo="w-storage-int-only", ) - pairs = list(ntuples_that_sum_to(numbers, sum_target, 2)) - logger.debug(f"found {len(pairs)} pair sequences that sum to {sum_target}") - # i promise mypy that we _are_ narrowing the types here - return t.cast(t.PairsOfNums, pairs) + already_seen = set() + summed = [] + for comparitor in numbers: + if (sum_target - comparitor) in already_seen: + summed.append((sum_target - comparitor, comparitor)) + already_seen.add(comparitor) + logger.debug(f"found {len(summed)} pair sequences that sum to {sum_target}") + return summed def triplets_that_sum_to( @@ -29,34 +30,15 @@ def triplets_that_sum_to( sum_kind="triplets", sum_target=sum_target, len_input=len(numbers), - algo="itertools", - ) - triplets = list(ntuples_that_sum_to(numbers, sum_target, 3)) - logger.debug(f"found {len(triplets)} triplet sequences that sum to {sum_target}") - # i promise mypy, again, that we _are_ narrowing the types here - return t.cast(t.TripletsOfNums, triplets) - - -def ntuples_that_sum_to( - numbers: t.Sequence[t.Num], sum_target: t.Num, dimensions: t.Int -) -> t.Generator[t.NTupelOfNums, None, None]: - """ - Filters a the input `numbers` by whether their n-tuple combinations - sum to match the `sum_target`, the n in n-tuple is controlled by `dimensions`. - e.g. pairs of numbers for dimenions==2 - - Returns - ------- - A generator from which we can pull the matching combinations. - - >>> input = [1, 2, 3, 4] - >>> list(ntuples_that_sum_to(input, 7, 3)) - >>> [(1, 2, 4)] - """ - n_tuples = itertools.combinations(numbers, dimensions) - filtered = ( - n_tuple - for n_tuple in n_tuples - if math.isclose(sum(n_tuple), sum_target, rel_tol=FLOAT_COMPARISON_REL_TOL) + algo="nested", ) - return filtered + summed = [] + for ileft, left in enumerate(numbers): + already_seen = set() + still_need = sum_target - left + for right in numbers[ileft + 1 :]: + if still_need - right in already_seen: + summed.append((left, (sum_target - left - right), right)) + already_seen.add(right) + logger.debug(f"found {len(summed)} triplet sequences that sum to {sum_target}") + return summed diff --git a/src/tuplesumfilter/types.py b/src/tuplesumfilter/types.py index 5b06c6d..34564fa 100644 --- a/src/tuplesumfilter/types.py +++ b/src/tuplesumfilter/types.py @@ -20,6 +20,7 @@ def read_file(fname: t.Path) cast = typ.cast +Boolean = bool Generator = typ.Generator Int = int List = typ.List diff --git a/tests/performance_check.py b/tests/performance_check.py index de545d2..bd26572 100644 --- a/tests/performance_check.py +++ b/tests/performance_check.py @@ -1,7 +1,10 @@ from pathlib import Path +import pytest from tuplesumfilter import numbers_in_file, pairs_that_sum_to, triplets_that_sum_to +pytestmark = pytest.mark.benchmark + INPUT1_FILE = Path("./tests/__test_data__") / "input1.txt" diff --git a/tests/test_floats_in_pairs_and_triplets.py b/tests/test_floats_in_pairs_and_triplets.py new file mode 100644 index 0000000..406c758 --- /dev/null +++ b/tests/test_floats_in_pairs_and_triplets.py @@ -0,0 +1,22 @@ +from math import e, pi, sqrt + +import pytest +from tuplesumfilter import pairs_that_sum_to, triplets_that_sum_to + +pytestmark = pytest.mark.floats + + +def test_triplets_works_approx_with_floats(): + root2 = sqrt(2) + nums = [pi, root2, 17.45, 1e-10, e] + target = pi + e + root2 + expected = [(pytest.approx(pi), pytest.approx(root2), pytest.approx(e))] + assert triplets_that_sum_to(nums, target) == expected + + +def test_pairs_works_approx_with_floats(): + root2 = sqrt(2) + nums = [pi, root2, 17.45, 1e-10, e] + target = pi + root2 + expected = [(pytest.approx(pi), pytest.approx(root2))] + assert pairs_that_sum_to(nums, target) == expected diff --git a/tests/test_pairs_that_sum.py b/tests/test_pairs_that_sum.py index 671518f..5afb43d 100644 --- a/tests/test_pairs_that_sum.py +++ b/tests/test_pairs_that_sum.py @@ -1,4 +1,3 @@ -from math import e, pi, sqrt import pytest from tuplesumfilter import pairs_that_sum_to @@ -28,13 +27,11 @@ def test_pairs_with_finite_input_but_no_match(worked_example_nums): assert pairs_that_sum_to(worked_example_nums, 0) == [] -def test_pairs_works_approx_with_floats(): - nums = [pi, sqrt(2), 17.45, 1e-10, e] - target = pi + e - expected = [(pytest.approx(pi), pytest.approx(e))] - assert pairs_that_sum_to(nums, target) == expected - - def test_pairs_when_multiple_matches(): example_input = [1, 979, 6, 299, 2, 1456, 5] assert pairs_that_sum_to(example_input, 7) == [(1, 6), (2, 5)] + + +def test_regression_pairs_excludes_repeats(): + got = pairs_that_sum_to([1, 2], 4) + assert got == [] diff --git a/tests/test_triplets_that_sum.py b/tests/test_triplets_that_sum.py index 9b3a100..84222cc 100644 --- a/tests/test_triplets_that_sum.py +++ b/tests/test_triplets_that_sum.py @@ -1,4 +1,3 @@ -from math import e, pi, sqrt import pytest from tuplesumfilter import triplets_that_sum_to @@ -32,13 +31,11 @@ def test_triplets_with_finite_input_but_no_match(worked_example_nums): assert triplets_that_sum_to(worked_example_nums, 0) == [] -def test_triplets_works_approx_with_floats(): - nums = [pi, sqrt(2), 17.45, 1e-10, e] - target = pi + e + sqrt(2) - expected = [(pytest.approx(pi), pytest.approx(sqrt(2)), pytest.approx(e))] - assert triplets_that_sum_to(nums, target) == expected - - def test_triplets_when_multiple_matches(): input = [10, 979, 5, 299, 2, 1456, 6, 8, 3] assert triplets_that_sum_to(input, 17) == [(10, 5, 2), (6, 8, 3)] + + +def test_regression_triplets_excludes_repeats(): + got = triplets_that_sum_to([1, 2, 7, 8, 9], 4) + assert got == []