Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

faster Run for known types #166

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

rogpeppe
Copy link
Contributor

@rogpeppe rogpeppe commented Aug 1, 2023

This is an alternative to PRs #160 and #165.
It's essentially the same as PR #165 except that it uses generics to reduce the amount of duplicated code.

Instead of just amortizing the checking of the type, when the argument type of the function passed to Run is known, it bypasses the reflect-based code altogether.
We don't bother implementing the optimization on pre-generics Go versions because those are end-of-lifetime anyway.

I've added an implementation-independent benchmark.

goos: linux
goarch: amd64
pkg: github.com/frankban/quicktest
cpu: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
                           │      base      │               thisPR               │
                           │     sec/op     │   sec/op     vs base               │
CNewAndRunWithCustomType-8    1077.5n ±  5%   136.8n ± 6%  -87.30% (p=0.002 n=6)
CRunWithCustomType-8         1035.00n ± 11%   66.43n ± 3%  -93.58% (p=0.002 n=6)
geomean                        1.056µ         95.33n       -90.97%

This is an alternative to PRs frankban#160 and frankban#165.
It's essentially the same as PR frankban#165 except that it uses
generics to reduce the amount of duplicated code.

Instead of just amortizing the checking of the type, when
the argument type of the function passed to `Run` is known,
it bypasses the reflect-based code altogether.
We don't bother implementing the optimization on pre-generics
Go versions because those are end-of-lifetime anyway.

I've added an implementation-independent benchmark.

```
goos: linux
goarch: amd64
pkg: github.com/frankban/quicktest
cpu: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
                           │      base      │               thisPR               │
                           │     sec/op     │   sec/op     vs base               │
CNewAndRunWithCustomType-8    1077.5n ±  5%   136.8n ± 6%  -87.30% (p=0.002 n=6)
CRunWithCustomType-8         1035.00n ± 11%   66.43n ± 3%  -93.58% (p=0.002 n=6)
geomean                        1.056µ         95.33n       -90.97%
```
Copy link
Owner

@frankban frankban left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

case runner[*testing.B]:
return fastRun1(c, name, f, t), true
case runner[*C]:
return fastRun1(c, name, f, t), true
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we return fastRun(t, name, f) here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be possible as a shortcut, but maybe a bit too clever. In fact this shortcut assumes that t is if of type *qt.C which may not be the case (a non-qt.C might have a Run(string, *qt.C) bool method).

Another alternative which is less clever, but still shortcuts (avoids to create one qt.C) while not corrupting the interface:

Suggested change
return fastRun1(c, name, f, t), true
return t.Run(name, f), true

Note that this shortcut is already implemented in #165.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I thought about this, and even implemented it, but decided it wasn't a good idea. For one, it only saves one allocation, which is a fairly minor saving in this context. For another, it means that it's not consistent with the other types, which always allocate a new C. Making this optimisation changes observable behaviour and because I'm not convinced it's a worthwhile optimisation anyway, I'd be inclined to leave it out.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch.

One observable difference would be the format function that should be propagated from the parent C. We obviously lack a test that would have caught this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test proposed in #167.

t.Errorf("TB isn't expected type (want %v got %T)", test.rtype(), c.TB)
}
})
if got, want := called, 1; got != want {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just if called != 1?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also maybe s/called/callCount/, as called feels like a boolean.

return false, false
}

type runner[T any] interface {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very nice way of not exposing generics in the public API!

Copy link
Contributor

@dolmen dolmen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generics are used here just to avoid duplicating the 5 lines of code in fastRun1.
This brings no performance benefits over #165 (in fact Go versions below 1.18 still get the reflect version which is not the case in #165), but makes the code much more messy. Because with generics come build tags to maintain.

The use of generics here is just over engineering.


func (customTForBenchmark) Run(name string, f func(testing.TB)) bool {
return true
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benchmarks could be moved to a separate PR as they would be useful for the other alternatives.

@dolmen
Copy link
Contributor

dolmen commented Aug 1, 2023

The commit message claims:

It's essentially the same as PR #165 except that it uses generics to reduce the amount of duplicated code.

Well. To remove 7 lines which, in #165, are duplicated just once 10 lines below (and not exact duplicate as this allows to call testing.T.Helper()), this implementation uses a succession of wrappers which add lines of code. Also the use of generics in a project where we aim to maintain compatibility with pre-generics versions of Go implies to isolate that clever implementation with build tags and bumping the Go version in go.mod (with a warning comment) which adds even more code.

Is it really worth it?

I don't think so. This is just a bad use case for generics.

Note: CI is failing on Go 1.13.

@dolmen
Copy link
Contributor

dolmen commented Aug 1, 2023

@rogpeppe @frankban In #165 I have just added a commit that factorizes the code which here is wrapped in fastRun1. In fact I've gone further as my factorization is also used in the reflect version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants