INTPYTHON-847 Add performance tests#366
Conversation
|
In general, awesome! Re:
Can this be integrated with existing tests such that some established testing conventions are followed? For example,
IOW If the |
Running the performance tests alongside the Django tests would add too much runtime to be practical. Putting the Is there a way to independently run tests on the |
|
I'm inclined to agree that the performance tests should be in their own directory Probably integrating with Django's |
| class TestSmallFlatDocCreation(SmallFlatDocTest, TestCase): | ||
| def do_task(self): | ||
| for doc in self.documents: | ||
| model = SmallFlatModel(**doc) | ||
| model.save() | ||
|
|
||
| def after(self): | ||
| SmallFlatModel.objects.all().delete() | ||
|
|
There was a problem hiding this comment.
I'm a little uncertain how this whole thing fits together. On the hand, you inherit django.test.TestCase which has its own setUp/tearDown strategy, on the other hand, you seem to implement your scheme with an after() method. Some classes use after(), others tearDown()... is it intentional? (edit: perhaps answered by the benchmark spec.)
There was a problem hiding this comment.
The ODM benchmark spec (draft PR here: mongodb/specifications#1828) is helpful here, but the short answer is yes, we need the ability to do setup or teardown both before and after every iteration of a test as well as the entire set of iterations.
| # Install django-mongodb-backend | ||
| /opt/python/3.10/bin/python3 -m venv venv | ||
| . venv/bin/activate |
There was a problem hiding this comment.
NIT:
Whilst I'd rather not have a new dependency on drivers-evergreen-tools, to future-proof the binary usage, you could add in the find_python function
There was a problem hiding this comment.
This I copied over from the existing run-tests.sh script here. Let's update them both in a separate ticket.
There was a problem hiding this comment.
We're going to bring in drivers-evergreen-tools anyway for QE
|
|
||
|
|
||
| def handle_perf(start_time: datetime): | ||
| end_time = datetime.now() |
There was a problem hiding this comment.
For duration calculations, it's better practice to use time.monotonic(); any particular reason to use datetime?
There was a problem hiding this comment.
This is copied from how PyMongo handles it for consistency. I believe we use it there for ease of the different ways we use the timestamps.
There was a problem hiding this comment.
I will give this file another look over -- only part remaining to be reviewed for me.
| result_data: list = [] | ||
|
|
||
|
|
||
| def tearDownModule(): | ||
| output = json.dumps(result_data, indent=4) | ||
| if OUTPUT_FILE: | ||
| with open(OUTPUT_FILE, "w") as opf: # noqa: PTH123 | ||
| opf.write(output) | ||
| else: | ||
| print(output) # noqa: T201 |
There was a problem hiding this comment.
Uff... I wonder if this blocks splitting up the tests into multiple files? At least it may need to become "append aware."
There was a problem hiding this comment.
Yes, splitting into multiple files is complicated by our infrastructure expecting a single JSON output file for the entire suite.
There was a problem hiding this comment.
After I suggested a default for OUTPUT_FILE, I now see that would break this if/else. Maybe we just remove OUTPUT_FILE from the documentation on how to run the tests? And actually, perhaps it's appropriate to remove this print statement also as I imagine the test by test output would be more useful than the JSON version meant for machine consumption?
There was a problem hiding this comment.
The JSON version is a nice simple summary that's useful for local use as well. Setting a default in the run-perf-tests.sh script that CI/CD uses makes sense, but we shouldn't set a default in the actual test suite iteself.
There was a problem hiding this comment.
I'm not going to argue, but my feeling is that:
TestSmallFlatDocUpdate 0.007 MB/s, MEDIAN=4.282s, total time=8.648s, iterations=2
is much easier to read than:
{
"info": {
"test_name": "SmallFlatDocUpdate"
},
"metrics": [
{
"name": "megabytes_per_sec",
"type": "MEDIAN",
"value": 0.007472334166114841,
"metadata": {
"improvement_direction": "up",
"measurement_unit": "megabytes_per_second"
}
}
]
}
and to get to the former, I have to scroll up past the huge JSON dump. (Of course, nothing is stopping someone from adding or removing that print statement while testing their changes.)
Incidentally, the JSON results don't include the elapsed time. Is it correct that we won't track that metric? The spec says, "In addition to latency data, ..."
Where can details about the metadata format (improvement_direction, etc.) be found?
There was a problem hiding this comment.
We don't report elapsed time in the JSON metrics, correct. The JSON metrics are tracked by Evergreen's charting, and we want a single metric easily comparable across ODMs there. We record elapsed time individually as part of each suite run as a secondary metric if needed for investigation.
The metadata format is for Evergreen chart tracking.
| tasks: | ||
| - name: perf-tests |
There was a problem hiding this comment.
In the PR description you wrote, "The full suite run locally is expected to take approximately 10 minutes." On the latest Evergreen build for this PR, it took 35 minutes... is it expected? That's a bit slower than the rest of our builds (~20 minutes). Is this going to run for every push to every PR? It's probably unnecessary. Perhaps it could be a very fast version with very low iterations just to ensure these tests don't break.
There was a problem hiding this comment.
That was for an earlier iteration that didn't fully implement the spec.
No, these tests are going to run once for each commit merged into master, not once per PR push.
There was a problem hiding this comment.
So they won't run at all on PRs? What do you think about my point about running with one/low iterations on PRs to ensure a PR doesn't break the suite?
There was a problem hiding this comment.
Since the suite runs public APIs only, I don't think having an extra suite just to test that the suite itself doesn't break is worth it. Anything that breaks this suite should be caught by other tests failing.
There was a problem hiding this comment.
But the performance tests are running on this PR... Is there some change we need to make before merging this so that they don't run on PRs? What happens when we need to make a change to the performance tests? Do we temporarily modify some trigger to have them run on that PR, then revert the trigger before merging?
There was a problem hiding this comment.
We'll have to update the Evergreen project settings to exclude the benchmark tests on PRs. Changes to the perf tests would require scheduling Evergreen patches to confirm changes work as expected.
bbf4547 to
92a680e
Compare
| Django MongoDB Backend uses a benchmarking suite to catch performance regressions. | ||
| This suite is located in the top-level ``performance_tests`` directory of the Django MongoDB Backend repository. |
There was a problem hiding this comment.
I'd also like to see a discussion (linking to the benchmark spec as appropriate) about what this is benchmarking (both Python code and MongoDB queries), how to interpret the results (Mb/sec??), where do we view the ongoing results, etc.
There was a problem hiding this comment.
I'm not sure what you mean here. We could add a link to the benchmark spec, which details the purpose and scope of the suite? There's already a link in the actual test file. Each run will have an evergreen chart attached to its run, there isn't a single place with all the results added dynamically.
There was a problem hiding this comment.
Basically, how can I use this tool to catch performance regressions? Am I supposed to run it locally and compare before and after? Is there a histogram of results? Will regressions be automatically detected? How will we know if the results are statistically significant?
Django has https://github.com/django/django-asv/tree/main and results displayed at https://django.github.io/django-asv/ but it's not obvious how to interpret and use the results, so I have similar concerns here.
Is this supposed to catch performance regressions in Django MongoDB Backend, MongoDB, or both? How can we differentiate?
The spec answers some questions but it's a long document with a lot of unnecessary details. Maybe high-level "cheat sheet" would be useful or other ODMs as well.
I suppose not all questions need to be answered before merging this (perhaps some answers are unknown), but this is what I've been thinking about.
There was a problem hiding this comment.
Short answer: https://jira.mongodb.org/browse/INTPYTHON-530 will set up automated alerting to be identical to our current PyMongo automated regression alerts. Here's a link to what the charting looks like for that, it'll look similar for Django: https://spruce.mongodb.com/task/mongo_python_driver_performance_benchmarks_perf_8.0_standalone_a89c5e3a89fa7e621f03fc9173067256a5fdc300_26_01_26_19_36_51/trend-charts?execution=0.
The expectation is that the benchmark suite will run up to once per day on commit merge and automatically alert us in Slack if any regressions are detected. No local runs are expected generally.
1fffd97 to
d26a8f2
Compare
d26a8f2 to
d25c35d
Compare
|
|
||
| OUTPUT_FILE = os.environ.get("OUTPUT_FILE") | ||
|
|
||
| if os.environ.get("FASTBENCH"): |
There was a problem hiding this comment.
Nit: It seems it would be more convenient for developers if "FASTBENCH" were the default and FULLBENCH were set by CI. Any objection to changing the default? Probably I should have put FASTBENCH=1 in my .bashrc by now because I keep forgetting to prefix test invokations with it, but why force devs to do that for the common case?
There was a problem hiding this comment.
We don't expect devs to regularly run these locally, so the default case is the CI run of the full benchmarks.
| self.document = json_util.loads(data.read()) | ||
|
|
||
| self.data_size = len(encode(self.document)) * self.num_docs | ||
| self.documents = [self.document.copy() for _ in range(self.num_docs)] |
There was a problem hiding this comment.
I notice this pattern of initializing self.documents but it seems wasteful of CPU/RAM to create all those identical documents.
Any reason not to refactor patterns like this:
for doc in self.documents:
LargeFlatModel.objects.create(**doc)
to:
for _ in self.num_docs:
LargeFlatModel.objects.create(**self.document)
?
There was a problem hiding this comment.
Good catch! Since we're creating Model instances with the documents, we don't actually need more than the single document.
There was a problem hiding this comment.
Here's some interesting data from making this change to the TestLargeFlatDocCreation benchmark:
before: 83.173 MB/s, MEDIAN=29.102s, total time=307.418s, iterations=10
after: 111.719 MB/s, MEDIAN=21.666s, total time=222.919s, iterations=10
It seems that iterating through a list is much costlier than iterating through a range.
I'm not a benchmarking guru, but to me, this perhaps highlights the importance of making benchmarks minimal so that we're benchmarking the backend and the database and not the efficiency of the benchmark task itself. In other words, it may be better to have many iterations of the same task rather than few iterations of a long task that has a loop within it. This is the approach taken for the benchmarks in Django's djangobench tool.
I offer this as food for thought, and I don't insist on revising the benchmarks prior to merging this.
| class TestLargeFlatDocFilterPkByIn(LargeFlatDocTest, TestCase): | ||
| """Benchmark for filtering large flat documents using the __in operator for primary keys.""" | ||
|
|
||
| def setUp(self): | ||
| super().setUp() | ||
| models = [] | ||
| for doc in self.documents: | ||
| models.append(LargeFlatModel(**doc)) | ||
| LargeFlatModel.objects.bulk_create(models) | ||
| self.ids = [model.id for model in models] | ||
|
|
||
| def do_task(self): | ||
| list(LargeFlatModel.objects.filter(id__in=self.ids)) |
There was a problem hiding this comment.
I see 10 benchmaks in the spec and 12 benchmarks implemented. I think this is the extra one. Did I miss it? I ask especially because this seems like a very inefficient operation that perhaps should be avoided. At least in SQL, huge "in" clauses are often naive solutions by inexperienced devs. The better solution is to to instead include the filter criteria that generated the huge list of ids in the subsequent query.
There was a problem hiding this comment.
If this is a likely operation for devs new to Django or MongoDB + Django, it's valuable to benchmark despite the inefficiency. Do we explicitly call out that this is a poor pattern in our docs?
The spec explicitly allows for extra benchmarks specific to each ODM, such as this one.
There was a problem hiding this comment.
Django MongoDB Backend doesen't have any query optimization docs like Django's. I thought maybe that Django docs mentions it, but I don't see anything.
Unless my intuition is incorrect, it seems that a lookup with a list of 10,000 ids (num docs) would be slow.
I'm just giving feedback based on the review item you wrote, "Do the Django operations being performed conform to our expectations of how django-mongodb-backend should be used?" I would say, "No, this is stupid code."
There was a problem hiding this comment.
If this operation is objectively inefficient for both SQL and MongoDB and we expect users to be aware of that, I'm fine removing it.
There was a problem hiding this comment.
I modified the __in benchmarks to query with a subset of ids which is more realistic.
| class Meta: | ||
| indexes = [ | ||
| models.Index(fields=["field1"], name="field1_idx"), | ||
| ] |
There was a problem hiding this comment.
Does the spec say anything about indexing (searching "index" didn't give any results)? I couldn't spot a reason why this field (and only this field) is indexed.
There was a problem hiding this comment.
This is the field used for the update operation tests. We expect ODMs to index fields if doing so is the typical use-case for their library.
There was a problem hiding this comment.
But the filter criteria for the update is the document's id. Naively, I wouldn't think an index on the value being updated would affect its performance (other than the the fact that the server needs to be incorporate the new value in the index, so the performance might be a bit worse, right?).
Is it important for the spec to dictate which fields should be indexed so that results can be fairly compared across ODMs?
There was a problem hiding this comment.
A future revision of the spec will likely specify which fields must be indexed. This first version is largely about catching regressions within an ODM, not comparing across ODMs.
|
|
||
|
|
||
| class StringEmbeddedModel(EmbeddedModel): | ||
| unique_field = models.CharField(max_length=100) |
There was a problem hiding this comment.
Should it have a unique constraint?
There was a problem hiding this comment.
I don't think it's relevant in this case, as this field always contains a stringified ObjectId.
There was a problem hiding this comment.
I ask because unique constraints may affect the query planner.
There was a problem hiding this comment.
I'd rather leave it as without a unique constraint to catch a wider spread of user scenarios.
d25c35d to
2bb8e0d
Compare
612977c to
5184bf7
Compare
| - name: performance-benchmarks | ||
| display_name: Performance Benchmarks |
There was a problem hiding this comment.
I noticed "benchmark" and "performance test" used interchangeably. I doubt it will cause much confusion, but it creates some ambiguity and a need to search for both terms to find related things.
There was a problem hiding this comment.
I'd say prefer "performance test" over "benchmark" …
Co-authored-by: Tim Graham <timograham@gmail.com>
Purpose
This is the first draft of the Django implementation of the MongoDB ODM Benchmarking suite. It contains a small suite of benchmark tests designed to measure django-mongodb-backend performance across data sizes and structures for what we expect to be common user operations.
This is NOT intended to be a comprehensive test suite for every operation, only the most common and widely applicable. This is also not intended to be Django specific: each of these tests must be implementable across all of our ODMs, up to the features supported by each library.
Structure
The benchmarks are contained within a separate Django application within
tests/performance/perftest. This application exists only to execute the benchmarking suite.Running Locally
After starting a local MongoDB server, the tests can be run by running
python manage.py testfrom thetests/performancedirectory. The full suite run locally is expected to take approximately 10 minutes. For faster benchmarks, passFASTBENCH=1as an environment variable totests/performance/perftest/tests.py.Review Items