In this post we are looking at the basic performance of the different sub-classing options in Django that have been discussed in previous posts. The three options are listed again below.

The three options

The three options that were discussed in previous posts and are now compared from a performance point of view are:

Option 1 – A single roads table

A single table called “ROADSA” contains the freeway and tollroad attributes as well as the common road attributes. Unused attributes for freeways or tollroads are set to NULL or similar values.

Option 2 – A table for each type of road

A ‘FREEWAYB’ table and a ‘TOLLROADB’ table are defined, each of which contains both shared road attributes and some attributes specific to the type of road.

Option 3 – Shared base table

The “road” information is stored in a ‘ROADC’ table, while the information about the freeway aspect of freeways is included in a ‘FREEWAYC’ table and the tollroad-specific aspects of tollroads are stored in a table called ‘TOLLROADC’.

Performance testing

First, I need to state that the testing reported here is neither thorough nor exhaustive. Its aim is to give some hints as to “default” performance for the different sub-classing models – but no attempt has been made to optimise performance. For example, the creation of instances has been done by reading a simple CSV file and creating the instances one by one, rather than using any sort of bulk import method.

All testing is done using SQLite and no transaction control is undertaken. As a result, all operations include a “BEGIN” statement. In some cases, this will double the number of queries reported.

Creation of roads

For each of the three options, 100 roads were created in a simple loop which created an object and saved it to the database. The tests were run three times for each option. Individual times are reported along with the averages. The query times reported are taken from the Django “connection” object imported from django.db.The total times for each test are found using the Python timeit module.

For Option 1, creating a new road requires two statements: a BEGIN statement and an INSERT into the ROADA table.

For Option 2, creating a new road requires requires two statements: a BEGIN statement and an INSERT into either the FREEWAYB or TOLLROADB table.

For Option 3, creating a new road requires four statements: a BEGIN statement; an INSERT into the ROADC table; an INSERT into and an UPDATE of either the FREEWAYC or TOLLROADC table. Note that the UPDATE statement is redundant in these tests.

The results are shown in the table below and the fastest option is shown in pink. However, it is worth noting that the times taken are very similar for all three options – the variation is less than 2.5%. Furthermore, the time spent in queries is longest for the fastest option, reflecting the fact that this simplest of options requires the least work within Django.

Description Total time for each test Average time Number of queries Time for queries for each test Average time
Create RoadAs 11.478; 10.863; 10.887 11.076 200 0.272; 0.116; 0.096 0.161
Create RoadBs 11.616; 10.886; 10.943 11.148 200 0.114; 0.138; 0.088 0.113
Create RoadCs 11.097; 12.267; 10.654 11.339 400 0.129; 0.121; 0.100 0.117

Selection of roads

Six selection operations are performed in this series of tests, based on the dataset imported in the creation stage. Each of the six selections returns different but consistent numbers of instances and the numbers selected will be the same for each dataset.

The six selection criteria fetch instances where:

  1. Name contains “ave” (ignoring case)
  2. SpeedLimit < 80
  3. UsageRestrictions contains “ton” (ignoring case)
  4. IsTollroad = True
  5. Criteria 1, 2 and 3
  6. Criteria 1, 2 and 4

Note that Criteria 1 and 2 are shared attributes, Criterion 3 is a Freeway attribute and Criterion 4 is a Tollroad attribute. This mix of selections is made easier or harder by the different modelling option used.

The results are shown in the table below and the fastest option is shown in pink. However, it is worth noting in this case, that the accuracy of the times limits the number of significant figures available, but the slowest option (Option 1) takes 50% longer than the fastest one. Once again, it is interesting to note that the time spent in queries is longest for the first option.

Option 2 requires more queries because in some of the selections there is a need to select from both Freeways and Tollroads.

Description Total time for each test Average time Number of queries Time for queries for each test Average time
Select RoadAs 0.012; 0.011; 0.014 0.012 6 0.003; 0.002; 0.001 0.002
Select RoadBs 0.008; 0.008; 0.009 0.008 8 0.002; 0.001; 0.001 0.001
Select RoadCs 0.008; 0.010; 0.010 0.009 6 0.001; 0.002; 0.001 0.001

Editing of roads

The editing operations involved using the same selection tests as shown above and then editing each of the roads selected in each set. The edits were chosen so that they did not change whether roads would be included by subsequent selections.

The attributes edited in each group were:

  1. Name
  2. SpeedLimit
  3. UsageRestrictions
  4. eTagType
  5. Name and SpeedLimit
  6. Name, SpeedLimit and HasTollBooth

The results are shown in the table below and the fastest option is shown in pink. Options 1 and 3 each take about 12% longer than Option 2. The figures for the third test of Option 3 seem significantly different from the two previous tests in both overall time and query time. If this sample is ignored, the results for Option 1 would be worst again, although only by a small margin.

Note that the number of queries varies between the different options, with Option 1 requiring the most and Option 3 the fewest.

Description Total time for each test Average time Number of queries Time for queries for each test Average time
Edit RoadAs 35.803; 35.318; 34.428 35.183 658 0.434; 0.330; 0.342 0.369
Edit RoadBs 32.501; 31.563; 30.319 31.461 576 0.270; 0.301; 0.351 0.307
Edit RoadCs 33.118; 33.919; 39.271 35.436 574 0.293; 0.409; 1.012 0.571

Deletion of roads

Deletion involved deleting all of the Road objects in the database. This is simplest for Option 1, where everything is stored in one table. Option 2 has simple storage in two tables and takes twice as many queries as a result. Option 3 is more complex and requires two queries to find the ids of the Freeways and the Tollroads and another two queries to delete those ids from the Freeway and Tollroad tables. The database then cleans up the Road table itself. Obviously this is considerably more work and for larger numbers of objects could become much slower than the simple statements used by other options.

Description Total time for each test Average time Number of queries Time for queries for each test Average time
Delete RoadAs 0.181; 0.109; 0.140 0.143 2 0.000; 0.001; 0.000 0.000
Delete RoadBs 0.458; 0.217; 0.229 0.301 4 0.001; 0.002; 0.001 0.001
Delete RoadCs 0.235; 0.126; 0.106 0.156 6 0.002; 0.002; 0.001 0.002


These results reflect only very small numbers of objects. What happens to performance if we have many more objects? Could some options perform relatively better or worse with larger numbers of objects?

 

 

Further performance testing

The aim of this performance testing is to compare the different sub-classing options when larger numbers of rows are involved. Testing was based on having 500,000 roads stored in the database for each of the three options – to see whether there was any discernible difference in behaviour. After inserting the three different types of roads, the SQLite database being used had 2 million rows in it.

Once again, I need to state that the testing reported here is neither thorough nor exhaustive. Its aim is to give some hints as to “default” performance for the different sub-classing models – but little attempt has been made to optimise performance.

All testing is done using SQLite and no transaction control is undertaken (except where noted, for example, in one case of road creation detailed below). As a result, all operations include a “BEGIN” statement. In some cases, this will double the number of queries reported.

In the tables below, there are occasional numbers in red text. These are numbers which are unexpectedly high for some reason.

Creation of roads

To import the large numbers of rows required, a CSV file with about 5,000 roads was imported 100 times into the SQLite database. As a result, 100 copies of each road exist for each option, but they are distinguished by different unique keys.

The Django bulk_create() function was used to insert the rows for Option 1 (RoadA) and Option 2 (RoadB) roads, but bulk_create() does not work for leaf models in multi-table inheritance, so for Option 3 (RoadC) the inserts were put inside a “with transaction.atomic()”  block to minimise transaction handling and maximise performance.

For each of the three options, 5,000 roads were created using bulk_create() (or in a single transaction for RoadC objects). The query times were taken from the Django “connection” object imported from django.db. The total times for each test are found using the Python timeit module.

The results for importing 5,000 roads for each option are shown in the table below and the fastest option is shown in pink. However, it is worth noting that the times for the two options that used the bulk_create() method are very similar – the variation is less than 1%. The third option is much slower, but performing all of the inserts in a single transaction is still vastly faster than the default auto commit method reported in the last newsletter.

One other point of interest is that, for each option, the difference between the average time for the first five lots of 5,000 roads (when the database is mostly empty) is only about 10% shorter than the time taken for inserting the last five lots of 5,000 roads – when there are almost 500,000 roads in the database. Even on a relatively cheap laptop, half a million rows is no longer very many.

Quite frankly, the performance for this sort of bulk insert is nothing short of amazing. Using the method reported in the last newsletter, the average time to create 5,000 roads is:

  • 607 seconds for Option 1,
  • 612 seconds for Option 2 and
  • 622 seconds for Option 3.

All very similar to each other, but very slow. In this example, the bulk_create() used for options 1 and 2 takes less than 0.1% of the time of the default create()/save() method. For option 3, performing the operations in one transaction takes only 0.5% of the time using the default auto commit method.

Description Total time for each test Average total time Number of queries Time for queries for each test Average query time
Bulk Create RoadAs 0.5216, 0.5543, 0.6098, 0.5076, 0.5328, 0.5447, 0.5677, 0.5301, 0.5997, 0.5768 0.5545 57 0.037, 0.045, 0.045, 0.046, 0.035, 0.044, 0.042, 0.049, 0.043, 0.049 0.04350
Bulk Create RoadBs 0.7473, 0.5652, 0.5305, 0.4913, 0.4896, 0.5348, 0.5316, 0.4930, 0.5142, 0.5906 0.5488 39 0.022, 0.020, 0.024, 0.020, 0.022, 0.019, 0.028, 0.023, 0.023, 0.027 0.02280
Single transaction Create RoadCs 3.1620, 3.2151, 3.1453, 3.2308, 3.2216, 3.1197, 3.1765, 3.2297, 3.2845, 3.2117 3.1997 15,000 0.360, 0.305, 0.432, 0.362, 0.355, 0.447, 0.353, 0.318, 0.425, 0.405 0.37617

Selection of roads

Six selection operations were performed in this series of tests, based on the dataset imported in the creation stage. Each of the six selections returned different but consistent numbers of instances, and the numbers selected were the same for each dataset.

The six selection criteria fetch instances where:

  1. Name contains “ave” (ignoring case)
  2. SpeedLimit < 80
  3. UsageRestrictions contains “ton” (ignoring case)
  4. IsTollroad = True
  5. Criteria 1, 2 and 3
  6. Maintainer contains “own” plus Criteria 2 and 4

Note that Criteria 1 and 2 are shared attributes, Criterion 3 is a Freeway attribute and Criterion 4 is a Tollroad attribute. This mix of selections is made easier or harder by the different modelling option used.

To make sure that times are significant enough to be compared, the tests were run ten times each – so almost 11,000,000 rows were fetched in the times quoted. The results are shown in the table below and the fastest option is shown in pink.

Once again, the performance is quite stunning. True, nothing is being done with the rows that have been fetched, but almost 11 million rows are being fetched in the reported time for each option, resulting in a fetch rate of:

  • 84,000 rows per second for Option 1,
  • 127,000 rows per second for Option 2 and
  • 122,000 rows per second for Option 3.

With such large numbers of rows to fetch, it is no surprise that the memory used by the test program reaches about 1GB.

Description Total time for each test Average time Number of queries Time for queries for each test Average time
Select RoadAs 131.103, 131.405, 131.322, 130.675, 131.218, 130.925, 131.489, 131.520, 130.616, 130.185 131.046 60 0.019, 0.026, 0.026, 0.021, 0.020, 0.019, 0.023, 0.020, 0.023, 0.025 0.022
Select RoadBs 86.992, 86.932, 86.718, 86.708, 86.373, 86.326, 86.504, 86.685, 86.759, 85.915 86.591 80 0.030, 0.034, 0.028, 0.032, 0.033, 0.030, 0.029, 0.032, 0.026, 0.030 0.030
Select RoadCs 89.700, 90.445, 90.718, 90.628, 90.132, 90.344, 90.469, 90.364, 90.015, 90.120 90.294 60 0.027, 0.028, 0.027, 0.027, 0.022, 0.025, 0.019, 0.022, 0.030, 0.027 0.025

Editing of roads

The editing operations involved using the same selection tests as shown above (though running them only once), and then editing 200 of the roads selected in each set. The edits were chosen so that they did not change whether roads would be included by subsequent selections.

The attributes edited in each group were:

  1. Name
  2. SpeedLimit
  3. UsageRestrictions
  4. eTagType
  5. Name and SpeedLimit
  6. Name, SpeedLimit and HasTollBooth

The results are shown in the table below and the fastest option is shown in pink. If one wished to separate the select/fetch and editing components of this test, the figures in the table above can be divided by 10 to give an indication of the time taken for selection and fetching. The remainder of the time given in the table below is taken by editing.

In summary, each test fetched about 1,100,000 objects and edited 1,200 of them in the time reported. The default transaction handling means that each edit takes place in its own transaction. If “with transaction.atomic()” is wrapped around the entire process, the editing process takes about 1 second.

Description Total time for each test Average time Number of queries Time for queries for each test Average time
Edit RoadAs 158.837, 157.378, 154.862, 156.839, 152.699, 155.927, 151.753, 152.190, 152.161, 143.677 153.632 2406 1.616, 1.277, 1.190, 1.329, 1.346, 2.206, 1.338, 1.437, 1.429, 1.118 1.4286
Edit RoadBs 171.791, 155.184, 154.153, 150.234, 150.732, 150.599, 150.106, 149.405, 149.900, 141.955 152.406 2408 3.387, 1.309, 1.38, 1.171, 1.241, 1.385, 1.355, 1.326, 1.266, 1.172 1.4992
Edit RoadCs 173.600, 158.416, 154.672, 154.304, 155.595, 152.904, 154.423, 153.338, 153.127, 144.549 155.493 2606 1.883, 1.454, 1.300, 2.050, 1.246, 1.252, 1.299, 1.322, 1.265, 1.148 1.4219