Skip to content

Allow normalizing measured timing by task complexity #285

Description

@opatut

So pytest-benchmark is already very good at comparing different variations of a test function, and also comparing different versions of code thanks to --benchmark-save and --benchmark-compare. Hurray!

I would love to also compare an operation's performance across different input sizes. Say I have an algorithm with two dimensions of input, n and m, and I assume the algorithm to have runtime O(n). To verify correct implementation I could choose a matrix of parameters for n and m, and divide all timings by n before printing to the comparison table, essentially measuring "runtime per element", not "runtime per operation".

I would assume to see a little overhead on small values for parameter n, but overall, the "runtime per element" would be approaching a constant factor for larger values of n. If this is not the case, or seemling different values for m cause a nontrivial amount of variation in the runtime per element, it would seem I have not truly achieved runtime O(n) and need to improve my implementation.

At the moment I'm eyeballing this from the results, approximating the division by input size in my head. This kind of works for linear time complexity, but if my algorithm is, say, logarithmic in time, I can forget about that and will need to create a spreadsheet. Of course, nobody wants that :P

It would be good if I could supply a timing normalization function per test case that operates on the parameters and some measured duration to compute a normalized duration. I could divide by log(n) in that function too, if I assume logarithmic time complexity.

I have achieved this doing some monkey patching, but of course that is ugly:

    from pytest_benchmark.stats import Metadata
    original = Metadata.update
    def monkeypatched_metadata_update(self, duration):
        self.stats.update(duration / self.iterations / n) # <----------- normalizing here!
    try:
        Metadata.update = monkeypatched_metadata_update
        benchmark(my_test)
    finally:
        Metadata.update = original

The result (I've edited the table a bit for clarity) shows nicely how my OPS or Mean appear to follow O(n) for large n and seem not to depend on m too much:

------------------------------------------------------ benchmark: 16 tests --------------------------
Name             n       m          Min               Max              Mean              OPS (Mops/s)          
-----------------------------------------------------------------------------------------------------
my_test     10.000        100   1.0851 (1.0)      1.6805 (1.50)     1.0983 (1.0)      910.4756 (1.00)    
my_test    100.000        100   1.0879 (1.00)     1.2075 (1.08)     1.1031 (1.00)     906.5639 (1.00)   
my_test     10.000      1.000   1.0968 (1.01)     1.8141 (1.62)     1.1113 (1.01)     899.8729 (0.99)   
my_test     10.000    100.000   1.0977 (1.01)     1.8788 (1.68)     1.1255 (1.02)     888.4684 (0.98)   
my_test     10.000     10.000   1.0982 (1.01)     1.5467 (1.38)     1.1231 (1.02)     890.4150 (0.98)   
my_test    100.000      1.000   1.0997 (1.01)     1.3460 (1.20)     1.1340 (1.03)     881.8282 (0.97)   
my_test    100.000     10.000   1.0997 (1.01)     1.2193 (1.09)     1.1234 (1.02)     890.1755 (0.98)   
my_test    100.000    100.000   1.1004 (1.01)     1.2076 (1.08)     1.1236 (1.02)     889.9990 (0.98)   
my_test  1.000.000        100   1.1068 (1.02)     1.3093 (1.17)     1.1334 (1.03)     882.3242 (0.97)   
my_test 10.000.000        100   1.1083 (1.02)     1.1192 (1.0)      1.1133 (1.01)     898.2257 (0.99)   
my_test  1.000.000      1.000   1.1180 (1.03)     1.2152 (1.09)     1.1327 (1.03)     882.8179 (0.97)   
my_test  1.000.000     10.000   1.1184 (1.03)     1.3050 (1.17)     1.1318 (1.03)     883.5809 (0.97)   
my_test 10.000.000      1.000   1.1185 (1.03)     1.1379 (1.02)     1.1256 (1.02)     888.3814 (0.98)   
my_test  1.000.000    100.000   1.1187 (1.03)     1.3280 (1.19)     1.1330 (1.03)     882.6440 (0.97)   
my_test 10.000.000     10.000   1.1532 (1.06)     1.2086 (1.08)     1.1646 (1.06)     858.6855 (0.94)   
my_test 10.000.000    100.000   1.1543 (1.06)     1.2005 (1.07)     1.1675 (1.06)     856.5528 (0.94)   
-----------------------------------------------------------------------------------------------------

It would be really good to have this as a quick and supported way, such as:

@pytest.mark.parametrize("n", [1, 10, 100, 1000, 1e5, 1e6, 1e7, 1e8])
@pytest.mark.parametrize("m", [1, 10, 100, 1000, 1e5, 1e6, 1e7, 1e8])
def test_my_test(benchmark, n, m):
   inputs = create_input_arrays(n, m)
   my_test = partial(my_implementation, inputs)

   # overwrite the normalization behaviour
   benchmark.normalize_duration = lambda duration: duration / n

   benchmark(my_test)

I'm very willing to implement this and create a PR.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions