The remainder of this document describes what is involved in adding optimizer support and provides a basic outline of how you might add this support to your database.
As with the previous lab, we recommend that you start as early as possible.
You should begin with the code you submitted for either Lab 5 or Lab 2. (If you did not submit code for Lab 2, or your solution didn't work properly, contact us to discuss options.)
We have provided you with extra test cases as well as source code files for this lab that are not in the original code distribution you received. We reiterate that the unit tests we provide are to help guide your implementation along, but they are not intended to be comprehensive or to establish correctness. You will need to add these new test cases to your release. The easiest way to do this is to untar the new code in the same directory as your top-level simpledb directory, as follows:
$ tar -cvzf CSE444-lab2-submitted.tar.gz CSE444-lab2
$ mv CSE444-lab2 CSE444-lab4
$ wget http://www.cs.washington.edu/education/courses/cse444/15sp/labs/lab4/CSE444-lab4-supplement.tar.gz
tar -xvzf CSE444-lab4-supplement.tar.gz
Here's a rough outline of one way you might proceed with this lab. More details on these steps are given in Section 2 below.
The optimizer will be invoked from simpledb/Parser.java. You may wish to review the lab 2 parser exercise before starting this lab. Briefly, if you have a catalog file catalog.txt describing your tables, you can run the parser by typing:
java -jar dist/simpledb.jar parser catalog.txt
When the Parser is invoked, it will compute statistics over all of the tables (using statistics code you provide). When a query is issued, the parser will convert the query into a logical plan representation and then call your query optimizer to generate an optimal plan.
Before getting started with the implementation, you need to understand the overall structure of the SimpleDB optimizer.
The overall control flow of the SimpleDB modules of the parser and optimizer is shown in Figure 1.
Figure 1: Diagram illustrating classes, methods, and objects used in the parser and optimizer.
The key at the bottom explains the symbols; you will implement the components with double-borders. The classes and methods will be explained in more detail in the text that follows (you may wish to refer back to this diagram), but the basic operation is as follows:
In the exercises to come, you will implement the methods that help physicalPlan devise an optimal plan.
.
When you launch SimpleDB, the entry point of the application is simpledb.Parser.main().
Starting from that entry point, describe the life of a query from its submission by the user to its execution. For this, list the sequence of methods that are invoked. For each method, describe its primary functions. When you describe the methods that build the physical query plan, discuss how the plan is built.
To help you, we provide the description of the first few methods. In general, however, it is up to you to decide on the appropriate level of detail for your description. Keep in mind that the goal is to demonstrate your understanding of the optimizer.
Life of a query in SimpleDB
Step 1: simpledb.Parser.main() and simpledb.Parser.start()
simpledb.Parser.main() is the entry point for the SimpleDB system. It calls simpledb.Parser.start(). The latter performs three main actions:
Step 2: simpledb.Parser.processNextStatement()
This method takes two key actions:
Step 3: simpledb.Parser.handleQueryStatement()
.... please continue describing the life of the query in the SimpleDB system....
Remember to describe the key steps involved in the construction of the physical query plan.
p=t1 join t2 join ... tn
, which signifies a left deep join where t1 is the left-most
join (deepest in the tree).
Given a plan like p
, its cost
can be expressed as:
scancost(t1) + scancost(t2) + joincost(t1 join t2) + scancost(t3) + joincost((t1 join t2) join t3) + ...Here,
scancost(t1)
is the I/O cost of scanning table t1,
joincost(t1,t2)
is the CPU cost to join t1 to t2. To
make I/O and CPU cost comparable, typically a constant scaling factor
is used, e.g.:
cost(predicate application) = 1 cost(pageScan) = SCALING_FACTOR x cost(predicate application)For this lab, you can ignore the effects of caching (e.g., assume that every access to a table incurs the full cost of a scan) -- again, this is something you may add as an optional bonus extension to your lab in Section 2.3. Therefore,
scancost(t1)
is simply the
number of pages in t1 x SCALING_FACTOR
.
joincost(t1 join t2) = scancost(t1) + ntups(t1) x scancost(t2) //IO cost + ntups(t1) x ntups(t2) //CPU costHere,
ntups(t1)
is the number of tuples in table t1.
ntups
can be directly computed for a base table by
scanning that table. Estimating ntups
for a table with
one or more selection predicates over it can be trickier --
this is the filter selectivity estimation problem. Here's one
approach that you might use, based on computing a histogram over the
values in the table:
Figure 2: Diagram illustrating the histograms you will implement in Lab 4.
In the next two exercises, you will code to perform selectivity estimation of joins and filters.
You will need to implement some way to record table statistics for selectivity estimation. We have provided a skeleton class, IntHistogram that will do this. Our intent is that you calculate histograms using the bucket-based method described above, but you are free to use some other method so long as it provides reasonable selectivity estimates.
We have provided a class StringHistogram that uses IntHistogram to compute selectivites for String predicates. You may modify StringHistogram if you want to implement a better estimator, though you should not need to in order to complete this lab.
After completing this exercise, you should be able to pass the IntHistogramTest unit test (you are not required to pass this test if you choose not to implement histogram-based selectivity estimation).
.
The class TableStats contains methods that compute the number of tuples and pages in a table and that estimate the selectivity of predicates over the fields of that table. The query parser we have created creates one instance of TableStats per table, and passes these structures into your query optimizer (which you will need in later exercises).
You should fill in the following methods and classes in TableStats:
After completing these tasks you should be able to pass the unit tests in TableStatsTest.
While implementing, your simple solution, you should keep in mind the following:
The class JoinOptimizer.java includes all of the methods for ordering and computing costs of joins. In this exercise, you will write the methods for estimating the selectivity and cost of a join, specifically:
Now that you have implemented methods for estimating costs, you will implement a Selinger-style optimizer. For these methods, joins are expressed as a list of join nodes (e.g., predicates over two tables) as opposed to a list of relations to join as described in class.
Translating the algorithm to the join node list form mentioned above, an outline in pseudocode would be as follows.
Hint: We discussed this algorithm in detail in class!
1. j = set of join nodes 2. for (i in 1...|j|): // First find best plan for single join, then for two joins, etc. 3. for s in {all length i subsets of j} // Looking at a concrete subset of joins 4. bestPlan = {} // We want to find the best plan for this concrete subset 5. for s' in {all length i-1 subsets of s} 6. subplan = optjoin(s') // Look-up in the cache the best query plan for s but with one relation missing 7. plan = best way to join (s-s') to subplan // Now find the best plan to extend s' by one join to get s 8. if (cost(plan) < cost(bestPlan)) 9. bestPlan = plan // Update the best plan for computing s 10. optjoin(s) = bestPlan 11. return optjoin(j)To help you implement this algorithm, we have provided several classes and methods to assist you. First, the method enumerateSubsets(Vector v, int size) in JoinOptimizer.java will return a set of all of the subsets of v of size size. This method is not particularly efficient; you can earn extra credit by implementing a more efficient enumerator.
Second, we have provided the method:
private CostCard computeCostAndCardOfSubplan(HashMap<String, TableStats> stats, HashMap<String, Double> filterSelectivities, LogicalJoinNode joinToRemove, Set<LogicalJoinNode> joinSet, double bestCostSoFar, PlanCache pc)
Given a subset of joins (joinSet), and a join to remove from this set (joinToRemove), this method computes the best way to join joinToRemove to joinSet - {joinToRemove}. It returns this best method in a CostCard object, which includes the cost, cardinality, and best join ordering (as a vector). computeCostAndCardOfSubplan may return null, if no plan can be found (because, for example, there is no linear join that is possible), or if the cost of all plans is greater than the bestCostSoFar argument. The method uses a cache of previous joins called pc (optjoin in the psuedocode above) to quickly lookup the fastest way to join joinSet - {joinToRemove}. The other arguments (stats and filterSelectivities) are passed into the orderJoins method that you must implement as a part of Exercise 4, and are explained below. This method essentially performs lines 6--8 of the psuedocode described earlier.
Note: While the original Selinger optimizer considered only left-deep plans, computeCostAndCardOfSubplan considers all linear plans.
Third, we have provided the method:
private void printJoins(Vector<LogicalJoinNode> js, PlanCache pc, HashMap<String, TableStats> stats, HashMap<String, Double> selectivities)This method can be used to display a graphical representation of the join costs/cardinalities (when the "explain" flag is set via the "-explain" option to the optimizer, for example).
Fourth, we have provided a class PlanCache that can be used
to cache the best way to join a subset of the joins considered so far
in your implementation of the Selinger-style optimizer (an instance of this class is
needed to use computeCostAndCardOfSubplan).
In JoinOptimizer.java, implement the method:
VectorThis method should operate on the joins class member, returning a new Vector that specifies the order in which joins should be done. Item 0 of this vector indicates the bottom-most join in a linear plan. Adjacent joins in the returned vector should share at least one field to ensure the plan is linear. Here stats is an object that lets you find the TableStats for a given table name that appears in the FROM list of the query. filterSelectivities allows you to find the selectivity of any predicates over a table; it is guaranteed to have one entry per table name in the FROM list. Finally, explain specifies that you should output a representation of the join order for informational purposes.orderJoins(HashMap<String, TableStats> stats, HashMap<String, Double> filterSelectivities, boolean explain)
You may wish to use the helper methods and classes described above to assist in your implementation. Roughly, your implementation should follow the psuedocode above, looping through subset sizes, subsets, and sub-plans of subsets, calling computeCostAndCardOfSubplan and building a PlanCache object that stores the minimal-cost way to perform each subset join.
After implementing this method, you should be able to pass the test OrderJoinsTest. You should also pass the system test QueryTest.
Now that you have a working optimizer, you can study the query plans that your optimizer generates.
In this exercise, we will use the same relational movie database as in 344. The data in this database is from the IMDB website. The database consists of six tables:
Actor (id, fname, lname, gender) Movie (id, name, year) Director (id, fname, lname) Casts (pid, mid, role) // Indicates which actor (pid references Actor.id) played in each movie (mid references Movie.id) Movie_Director (did, mid) // Indicates which director (did references Director.id) directed which movie (mid references Movie.id) Genre (mid, genre)
We provide you with the following:
The QueryPlanVisualizer will print the whole query plan for each query. If you would like to see more information about the joins, you can launch SimpleDB with the -explain option enabled:
java -classpath "bin/src/:lib/*" simpledb.Parser $IMDB_DATA_FOLDER/imdb.schema -explain
6.1 Execute the following query
select d.fname, d.lname
from Actor a, Casts c, Movie_Director m, Director d
where a.id=c.pid and c.mid=m.mid and m.did=d.id and a.fname='John' and a.lname='Spicer';
Show the query plan that your optimizer selected. Explain why your optimizer selected that plan. Be careful as the plan may be different for the 1%, 0.1%, and 10% datasets (you do not need to test with all the datasets, just pick one).
6.2 Execute another SQL query of your choice over the IMDB database. Show the query plan that your optimizer generates. Discuss why your optimizer generates that plan. Try to find an interesting SQL query with a combination of joins and selections.
.
In this section, we describe several possible extensions to your query optimizer. These are less well defined than the previous exercises but give you a chance to show off your mastery of query optimization!
As part of the project, we ask you to pick one or more of the following extensions. Alternatively, you can also implement an extension of your choice.
Make sure to develop unit-tests and system tests for verifying the correctness of your extension. Please use JUnit when appropriate but feel free to also go beyond JUnit and use scripts if that helps you test some interesting, additional configurations. For the tests that you add, please add them to a separate directory called test-extensions.
1) Add code to perform more advanced join cardinality estimation. Rather than using simple heuristics to estimate join cardinality, devise a more sophisticated algorithm.
One option is to use joint histograms between every pair of attributes a and b in every pair of tables t1 and t2. The idea is to create buckets of a, and for each bucket A of a, create a histogram of b values that co-occur with a values in A.
Another way to estimate the cardinality of a join is to assume that each value in the smaller table has a matching value in the larger table. Then the formula for the join selectivity would be: 1/(Max(num-distinct(t1, column1), num-distinct(t2, column2))). Here, column1 and column2 are the join attributes. The cardinality of the join is then the product of the cardinalities of t1 and t2 times the selectivity.
2) Improved subset iterator. Our implementation of enumerateSubsets is quite inefficient, because it creates a large number of Java objects on each invocation. A better approach would be to implement an iterator that, for example, returns a BitSet that specifies the elements in the joins vector that should be accessed on each iteration. In this exercise, you would improve the performance of enumerateSubsets so that your system could perform query optimization on plans with 20 or more joins (currently such plans takes minutes or hours to compute).
3) A cost model that accounts for caching. The methods to estimate scan and join cost do not account for caching in the buffer pool. You should extend the cost model to account for caching effects. This is tricky because multiple joins are running simultaneously due to the iterator model, and so it may be hard to predict how much memory each will have access to using the simple buffer pool we have implemented in previous labs.
4) Improved join algorithms and algorithm selection. Our current cost estimation and join operator selection algorithms (see instantiateJoin() in JoinOptimizer.java) only consider nested loops joins. Extend these methods to use one or more additional join algorithms (for example, some form of in memory hashing using a HashMap).
5) Bushy plans. Improve the provided orderJoins() and other helper methods to generate bushy joins. Our query plan generation and visualization algorithms are perfectly capable of handling bushy plans; for example, if orderJoins() returns the vector (t1 join t2 ; t3 join t4 ; t2 join t3), this will correspond to a bushy plan with the (t2 join t3) node at the top.
6) A new suite of test cases. The unit tests provided for this lab are not very good. Devise a new set of unit tests that better exercise the functionality developed in this lab.
You have now completed this lab. Good work!
You must submit your code (see below) as well as the final project report as per the instructions here.
To submit your code, please create a CSE444-lab4.tar.gz tarball (such that, untarred, it creates a CSE444-lab4 directory with your source code at CSE444-lab4/src/java/simpledb). Include your individual writeup in the tarball that you submit:
$ cp lab4_writeup.pdf CSE444-lab4 $ tar -cvzf CSE444-lab4.tar.gz CSE444-lab4
Please do not use the ant handin target to create your submission archive.
Submit your tarball for the Lab 4 assigment to the dropbox.. You may submit your code multiple times; we will use the latest version you submit that arrives before the deadline (before 11:45pm on the due date). Please submit your final report as a PDF or word document (.doc or .docx).
SimpleDB is a relatively complex piece of code. It is very possible you are going to find bugs, inconsistencies, and bad, outdated, or incorrect documentation, etc.
We ask you, therefore, to do this lab with an adventurous mindset. Don't get mad if something is not clear, or even wrong; rather, try to figure it out yourself or send us a friendly email. Please submit (friendly!) bug reports to the course staff. When you do, please try to include:
test/simpledb
directory, compile, and run.
HeapFileEncoder
.
See the final project instructions here.
Important: before testing, we will replace your build.xml, HeapFileEncoder.java, and the entire contents of the test/ directory with our version of these files! This means you cannot change the format of .dat files! You should therefore be careful changing our APIs. This also means you need to test whether your code compiles with our test programs. In other words, we will untar your tarball, replace the files mentioned above, compile it, and then grade it. It will look roughly like this:
$ gunzip CSE444-lab4.tar.gz $ tar xvf CSE444-lab4.tar $ cd CSE444-lab4 [replace build.xml, HeapFileEncoder.java, and test] $ ant test $ ant systemtest [additional tests]
If any of these commands fail, we'll be unhappy, and, therefore, so will your grade.
For the tests that you add, please add them to a separate directory called test-extensions.
We've had a lot of fun designing this assignment, and we hope you enjoy hacking on it! Last modified: Mon May 18 22:32:40 PDT 2015