Monday, 17 April 2017

subselect: execution (5)


I want to come back to my initial plan and look into the code, following the flow of C-statements while executing one given SQL-statement. For this text I will look in detail how the server executes the WHERE-clause. I will start my description with the the WHERE applied to the table TestBig (the subselect-part), and then look at the handling of the WHERE for the table TestSmall.


warning

Before I describe my observation let me issue a warning: the functions, hierarchies and data-examples shown here are valid for the environment on my PC. If you do your own tests this may look a bit different.


environment

Again I'm using my usual environment which I already described in one of my older posts. It's the same tables, the same data in the tables, it's even the same SQL-statement as in my last post. But for this post my focus changes. In this text I will look into the code that realizes the WHERE-clause.


the SQL-statement

For this text this is the statement I want to inspect:
MariaDB [TestOpt]> select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN) and B.Hersteller = '00020';
+--------+----------------------------+
| PZN    | ArtikelText                |
+--------+----------------------------+
| 178324 | TINY TOON ADVENTURES KIND  |
| 87886  | MONDOS KONDOME COLOR       |
| 87923  | MONDOS KONDOME NATURE      |
| 87952  | MONDOS KONDOME XXL         |
+--------+----------------------------+
4 rows in set (25 min 4.84 sec)

MariaDB [TestOpt]> 



WHERE

As I've written in my last text the WHERE-clause for the table TestBig looks like that:

For this text I will sharpen my look and will only look at this detail: the WHERE-clause. So where is the code that executes this WHERE?


the code

You will find the code for handling the WHERE in the file sql_select.cc:
static enum_nested_loop_state
evaluate_join_record(JOIN *join, JOIN_TAB *join_tab,
                     int error)
{
  ....
  COND *select_cond= join_tab->select_cond;
  ....
  if (select_cond)
  {
    select_cond_result= MY_TEST(select_cond->val_int());
  ....
}
I've marked the relevant part in bold.

MY_TEST is simply a macro that converts any output into 1 or 0. So the WHERE-clause is represented by the call of val_int() of the object pointed to by the variable select_cond. That's all.

Naturally that's not all. For this concrete example I will show you how the tree, given in graphical form above, is handled by this simple call. The tree is constructed somewhere else, but this is not part of this text. So let's simply start with an example to show the function-hierarchy that is equivalent to the tree.


applying WHERE to TestBig

And as I've already written I will start with the WHERE applied to records read from the table TestBig.

So here is the record that I will examine:
MariaDB [TestOpt]> select * from TestBig limit 1;
+----------+--------+--------+------+------------------------------------+----------------------------+------------+
| Id       | PZN    | EVP    | HAP  | ArtikelBez                         | ArtikelText                | Hersteller |
+----------+--------+--------+------+------------------------------------+----------------------------+------------+
| 01000000 | 999999 | 132.36 | 0.00 | UROSAFE BEINBEUTEL 7732C      1  L | SPRING DE LU AM K1 NO TOP5 | 25677      |
+----------+--------+--------+------+------------------------------------+----------------------------+------------+
1 row in set (0.00 sec)

MariaDB [TestOpt]>

As you can see the value in the column Hersteller does not match the condition so the left part of the tree results in false. As the right and the left part of the tree are combined via an AND-statement the right part does not need to be examined.

first example: left part of the tree

For the record given here his is the hierarchy that detects and handles this:
Item_cond_and::val_int()        called via the variable select_cond
    Item::val_bool()
        Item_func_eq::val_int()
            Arg_comparator::compare()
                Arg_comparator::compare_string()
                    Item_field::val_str()
                    Item_string::val_str()
                    sortcmp()

The function sortcmp() returns with a value of -1 ( = not equal to 0) because the values differ (36367 ≠ 25677). So the code jumps back, the function item_func_eq::val_int() sets the return-value to 0 (= false) and at the AND-level will returns with false as a return-value.
This record from TestBig is thrown away. The server continues with reading the next record from TestBig.

second example: right part of the tree

The next record fetched from TestBig looks like this:
MariaDB [TestOpt]> select * from TestBig limit 1,1;
+----------+------+------+-------+-------------------------+---------------------+------------+
| Id       | PZN  | EVP  | HAP   | ArtikelBez              | ArtikelText         | Hersteller |
+----------+------+------+-------+-------------------------+---------------------+------------+
| 01000001 | 111  | 0.00 | 18.91 | PK FUER TIERE    250 ML | URETERSCHIENEAC4P66 | 36367      |
+----------+------+------+-------+-------------------------+---------------------+------------+
1 row in set (0.00 sec)

MariaDB [TestOpt]> 

As you can see for this record the column Hersteller contains the correct value so the left part of the tree returns true. Now the right part has to be examined. As I've already described the left part of the tree I will generously skip over this part. Here is how this record is checked:
Item_cond_and::val_int()        called via the variable select_cond
    // checking the column Hersteller, same as above, omitted here
    // as the return-value is true for this case the comparison continues:
    Item::val_bool():   
        Item_func_eq::val_int()
            Arg_comparator::compare()
                Arg_comparator::compare_string()
                    Item_field::val_str()
                    Item_field::val_str()
                    sortcmp()

The only difference to the hierarchy given above is inside the function compare_string(). Now two columns have to be compared instead of a column with a string-constant, so compare TestSmall.PZN and TestBig.PZN. As the values are 111 (for A aka TestBig) and 12 (for B = TestSmall, shown later in this text) so the comparisons results in false (aka not equal) and this record is thrown away. The server continues with reading the next record from TestBig and so on.

As you can see from the box in the beginning of this text there are 4 records that will be returned by this statement so there must be 4 records in the table TestBig where both parts of the tree result in true. These records are handled further by the server but this is not part of this text.

coding the AND

Here is the code that realizes the AND:
longlong Item_cond_and::val_int()
{
  DBUG_ASSERT(fixed == 1);
  List_iterator_fast li(list);
  Item *item;
  null_value= 0;
  while ((item=li++))
  {
    if (!item->val_bool())
    {
      if (abort_on_null || !(null_value= item->null_value))
    return 0;               // return FALSE
    }
  }
  return null_value ? 0 : 1;
}

The while-loop realizes the AND, that's all. If one of the computations inside the brackets returns a false the whole statement is false (it's an AND !) so the function is left with a return-value FALSE.


applying WHERE to TestSmall

So I've shown how the WHERE in the subselect-part of the statement is handled. Now I want to show how the WHERE is applied to the table TestSmall. And this is, in graphical form, the condition to be applied:

third example: checking TestSmall

Here is the first record from TestSmall that that matches the WHERE-condition:
MariaDB [TestOpt]> select * from TestSmall where Hersteller = '00020' limit 1;
+---------+------+------+------+--------------------------------+----------------------------+------------+
| Id      | PZN  | EVP  | HAP  | ArtikelBez                     | ArtikelText                | Hersteller |
+---------+------+------+------+--------------------------------+----------------------------+------------+
| 1002100 | 12   | 3.95 | 1.83 | TINY TOON ADVENTURES KIND 1 ST | TINY TOON ADVENTURES KIND  | 00020      |
+---------+------+------+------+--------------------------------+----------------------------+------------+
1 row in set (0.04 sec)

MariaDB [TestOpt]> 

Again I omit the left part of the tree as it's already shown above. So let's look at what happens on the right side of this tree. Here is the function-hierarchy:
Item_cond_and::val_int()        called via the variable select_cond
    // checking the column Hersteller, same as above, omitted here
    // as the return-value is true for this case the comparison continues:
    Item_cache_wrapper::val_bool()
        Item_cache_wrapper::cache()
            Item_cache_int::cache_value()
                Item::val_int_result() 
                    Item_exists_subselect::val_int()
                        Item_subselect::exec()
                             subselect_single_select_engine::exec()
                                JOIN::exec()
                                    JOIN::exec_inner()
                                        do_select()
                                            sub_select() 
                                                // read record from table
                                                // evaluate this record
                                                // until EOF

In the graphical representation the right part of the tree simply contains the box SUBSELCT. This is a statement of its own which is handled as (partially) described above. The result is put into a cache. The hierarchy shows in the lower parts how the table TestBig is scanned and in the upper parts how the cache is checked for results. But that's another story.



correctness

This text is a simplified presentation of what's going on in the software. As I'm still learning the inner-workings of this software errors on my side can happen. In case something is wrong with my description please use the mail-function of this site and send me a mail describing my error. I will look at it. Thanks.


Wednesday, 15 February 2017

subselect: execution (4)


Initially I didn't plan to write this text. My intention was to write how the server executes a special statement but by collecting the data for my text I found something that was more interesting than my initial idea (the same happened with the last text). I decided to put my plan aside and write about my observation.

As this text is about engine condition pushdown (ECP) implemented in MariaDB so please look into an older post of mine with the information about this topic: WHERE. I will use the information presented there and especially the piece of code described in this text.

I want to show and describe what the engine condition pushdown reports on my SQL-statement and to make it short: I don't like the result. Maybe it's a bug in the code, maybe I did something wrong. Let's start with the example.


warning

Before I describe my observation let me issue a warning: the functions, hierarchies and data-examples shown here are valid for the environment on my PC. If you do your own tests this may look a bit different.


environment

Again I'm using my usual environment which I already described in one of my older posts. It's the same tables, the same data in the tables etc.. I've changed the SQL-statement and therefore my focus changes. I'm looking in the handling of the extreme-situation when no indexes are defined on the tables (silly, but the server has to handle this somehow). In this text I want to describe the contents of the condition-tree; as this happens in the layer above the storage-engine I think my description is valid for all engines but I didn't check this. Nevertheless I want to tell you I've used the MyISAM-engine for my tests.


the SQL-statement

For this text this is the statement I want to inspect:
MariaDB [TestOpt]> set optimizer_switch='engine_condition_pushdown=on';
Query OK, 0 rows affected (0.00 sec)

MariaDB [TestOpt]> select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN) and B.Hersteller = '00020';

No result given here, I will focus on the data in the condition-tree.


the condition-tree

As condition-pushdown is enabled for this test the function ha_myisam::cond_push() is called and it is called multiple times as I will show you soon. Using the code given here I will receive this output in the console-window of Eclipse:
table_name = <TestSmall>    <select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN) and B.Hersteller = '00020'>
COND-ITEM   args: 0 type=[COND_AND_FUNC]    
FUNC-ITEM   [=] args: 2 type=[EQ_FUNC]  
FIELD-ITEM  [TestOpt] [B] [Hersteller]  name=<Hersteller>
STRING-ITEM     str_length=<5>  str_value=<00020>   name=<00020>
SUBSELECT-ITEM  

table_name = <TestBig>  <select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN) and B.Hersteller = '00020'>
COND-ITEM   args: 0 type=[COND_AND_FUNC]    
FUNC-ITEM   [=] args: 2 type=[EQ_FUNC]  
FIELD-ITEM  [TestOpt] [A] [Hersteller]  name=<Hersteller>
STRING-ITEM     str_length=<5>  str_value=<36367>   name=<36367>
FUNC-ITEM   [=] args: 2 type=[EQ_FUNC]  
FIELD-ITEM  [TestOpt] [A] [PZN] name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN] name=<PZN>

This text will appear before any data is read from the tables.

This form looks ugly so I will present the same data (= the condition-tree) in a better form:
for the table TestSmall:

and for the table TestBig1):

These 2 trees were given to the function ha_myisam::cond_push() before any data was read from the tables. So let's go to the execution-stage.


executing the statement

As this is the same statement as in my last post so please look into this text for a description of the steps for executing this statement.

The execution begins with a table-scan on the table TestSmall. Before starting this table-scan the function cond_push() is called again with the condition-tree for this table which is a little bit modified, but this is not of interest here. Then reading the table TestSmall starts and when a record matching the condition is found the server switches over to the table TestBig and searches for corresponding records in this table. As described the only way to do this is by a table-scan. Before this table-scan starts cond_push() is called again, this time with the condition-tree for the table TestBig (the call is done in init_read_record()). And this condition-tree is of interest, it looks like this:
table_name = <TestBig>  <select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN) and B.Hersteller = '00020'>
COND-ITEM   args: 0 type=[COND_AND_FUNC]    
FUNC-ITEM   [=] args: 2 type=[EQ_FUNC]  
FIELD-ITEM  [TestOpt] [A] [Hersteller]  name=<Hersteller>
STRING-ITEM     str_length=<5>  str_value=<36367>   name=<36367>
FUNC-ITEM   [=] args: 2 type=[EQ_FUNC]  
FIELD-ITEM  [TestOpt] [A] [PZN] name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN] name=<PZN>
Of interest are the last 2 lines, for this reason I've marked them in bold. For the next examples I will only present these lines.

If you compare this output with the output presented some lines above you will not see any difference. Instead of explaining what I expected I would like to let the server continue it's work: it starts reading record after record from TestBig and when a match is found it stops reading TestBig and returns to TestSmall. There it continues reading.

When the next match is found in TestSmall it will switch over to the table TestBig again. Before reading the first record from TestBig it calls cond_push(). The line of interest now looks like this:
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<2>  str_value=<37>  name=<PZN>

Something has changed in the condition-tree, I've marked this part in bold: the line contains a value from the column PZN of the current record in TestSmall (aka B). Let's loop the server through the records in TestBig, maybe until the end when no match is found. It then returns to the table TestSmall and continues reading records from this table.

OK, this behaviour repeats, in total 60 times (for my data here, as described in my last post). So I want to present only these 2 lines from the output of the condition-tree and only for the first 5 calls2):
1st match (repeated):
FIELD-ITEM  [TestOpt] [A] [PZN]                                      name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN]                                      name=<PZN>

2nd match:
FIELD-ITEM  [TestOpt] [A] [PZN] str_length=<6>  str_value=<      >   name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<2>  str_value=<37>       name=<PZN>

3rd match:
FIELD-ITEM  [TestOpt] [A] [PZN] str_length=<6>  str_value=<      >   name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<7>  str_value=<222    >  name=<PZN>

4th match:
FIELD-ITEM  [TestOpt] [A] [PZN] str_length=<3>  str_value=<<   >     name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<3>  str_value=<371>      name=<PZN>

5th match:
FIELD-ITEM  [TestOpt] [A] [PZN] str_length=<6>  str_value=<      >   name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<7>  str_value=<3717997>  name=<PZN>

and so on

For getting the information presented above I used the member-functions of the Item-class (aka COND-class) of derived classes especially the member str_value (of type String). If this is the wrong way to access the information please drop me a line. Thanks.

So let's look at the data and compare these values with the data in the box above:
MariaDB [TestOpt]> select * from TestSmall where Hersteller = '00020' limit 5;
+---------+---------+------+------+--------------------------------+----------------------------+------------+
| Id      | PZN     | EVP  | HAP  | ArtikelBez                     | ArtikelText                | Hersteller |
+---------+---------+------+------+--------------------------------+----------------------------+------------+
| 1002100 | 12      | 3.95 | 1.83 | TINY TOON ADVENTURES KIND 1 ST | TINY TOON ADVENTURES KIND  | 00020      |
| 1025266 | 3717968 | 0.00 | 2.90 | PRESSOTHERM KALT/WA 13X14 1 ST | PRESSOTHERM KALT/WA 13X14  | 00020      |
| 1025267 | 222     | 0.00 | 4.45 | PRESSOTHERM KALT/WA 12X29 1 ST | PRESSOTHERM KALT/WA 12X29  | 00020      |
| 1025268 | 3717980 | 0.00 | 6.30 | PRESSOTHERM KALT/WA 16X26 1 ST | PRESSOTHERM KALT/WA 16X26  | 00020      |
| 1025269 | 3717997 | 0.00 | 6.30 | PRESSOTHERM KALT/WA 20X20 1 ST | PRESSOTHERM KALT/WA 20X20  | 00020      |
+---------+---------+------+------+--------------------------------+----------------------------+------------+
5 rows in set (0.06 sec)

MariaDB [TestOpt]> 


expectations

So what did I expect? Engine condition pushdown gives the storage engine a chance to use the information in the WHERE-clause of the statement to speed-up a query. I like the idea.

In my case here the function cond_push() is initially called for the table TestSmall and then for the table TestBig. Then the server starts reading the table TestSmall and calls cond_push() again. Agreed. When it switches over to the table TestBig it calls cond_push() again, giving it the condition-tree for this table. Again: agreed. But if I look at the 5 outputs of the condition-tree I do not see the values found in the PZN-column shown above.


inspecting the condition-tree

The function cond_push() is called before any real access of the data-file happens When this happens the first time a value of 12 is found in the column PZN of the table TestSmall and I expected to see this value in the corresponding line:
output found:
FIELD-ITEM  [TestOpt] [A] [PZN]                                      name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN]                                      name=<PZN>

output expected:
FIELD-ITEM  [TestOpt] [A] [PZN] str_length=<6>  str_value=<      >   name=<PZN>
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<2>  str_value=<12>       name=<PZN>

So let me show in short form the output (the last line of the condition-tree-output) of all 5 calls of cond_push() that I would expect:
1st match:
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<2>  str_value=<12>       name=<PZN>

2nd match:
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<7>  str_value=<3717968>  name=<PZN>

3rd match:
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<3>  str_value=<222>      name=<PZN>

4th match:
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<7>  str_value=<3717980>  name=<PZN>

5th match:
FIELD-ITEM  [TestOpt] [B] [PZN] str_length=<7>  str_value=<3717997>  name=<PZN>

and so on

Please compare these lines with the real output, presented some lines above. These are the differences:
  • the value from the current record of TestSmall is given in the correct form in the condition-tree representing the WHERE-condition for TestBig
  • currently the length-information is incorrect, it's one row behind. This should be the length of the string in the current row, e.g. compare the length-information for the 3rd match: actual it's 7 but it should be 3.


evaluation of the results

Engine condition pushdown gives the storage-engine the information which it can use internally to speed-up the request. It should be given the correct information and all the information available. For the statement presented here this means that when cond_push() is called before the first access of the table TestBig the condition-tree should contain the entry 12 (= the value of the column PZN of the current row of TestSmall in my example). And for the 2nd access the tree should contain the value 3717968 (= value of the column PZN of the then current row of the table TestSmall. And so on.


conclusion

I see these 2 possibilities:

  • it's a bug in the code
OR
  • the information is there but I used the wrong function for accessing it

I will ask the developers.

update

2017/05/10: problem fixed. You will find a corrected version of the code presented here in this file: WHERE.cc. With this code I got the the values as expected.




correctness

This text is a simplified presentation of what's going on in the software. As I'm still learning the inner-workings of this software errors on my side can happen. In case something is wrong with my description please use the mail-function of this site and send me a mail describing my error. I will look at it. Thanks.




some notes:
1) I had to give the equal-functions different names otherwise the code for drawing the tree would have some trouble with drawing the correct relations
2) let's ignore the first (initial call to cond_push(), I want to look at the calls before the table-scan starts

Friday, 27 January 2017

subselect: execution (3)


It wasn't planned to write a text with this content. Initially I wanted to write how the server executes another statement but when I played with the statement I found this behaviour of the server (described some lines below) and I decided to write something about this.

In my last post (subselect: execution (2)) I played with a special SQL-statement, a modified version of a statement I've used before using a subselect, and I showed how this statement is treated in the execution-stage of the server. For this text I simply change the IN-clause in the statement into an EXISTS-clause and look what's happening then. I only did this test in MariaDB, it's up to you to do these test again in MySQL if you are interested in it.

In this text I'm using information which I've already described in older posts and I don't want to repeat this information here, so please look at these posts: caches and od.


warning

Before I describe my observation, my ideas and my fix let me issue a warning: the functions, hierarchies and data-examples shown here are valid for the environment on my PC. If you do your own tests this may look a bit different.


environment

Again I'm using my usual environment which I already described in one of my older posts. It's the same tables, the same data in the tables etc.. I've changed the SQL-statement and therefore my focus changes. I'm looking in the handling of the extreme-situation when no indexes are defined on the tables (silly, but the server has to handle this somehow). In the older texts in this series I stopped my description when a call into the storage-engine is made. In this text I will mainly look into the code of the storage-engine and the engine used for storing the data in the tables is MyISAM, things may look different with other engines.


the SQL-statement

For this text this is the statement I want to inspect:
MariaDB [TestOpt]> select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN) and B.Hersteller = '00020';
+--------+----------------------------+
| PZN    | ArtikelText                |
+--------+----------------------------+
| 178324 | TINY TOON ADVENTURES KIND  |
| 87886  | MONDOS KONDOME COLOR       |
| 87923  | MONDOS KONDOME NATURE      |
| 87952  | MONDOS KONDOME XXL         |
+--------+----------------------------+
4 rows in set (25 min 4.84 sec)

MariaDB [TestOpt]> 


Let me add the explain the server returns:
MariaDB [TestOpt]> MariaDB [TestOpt]> explain select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN) and B.Hersteller = '00020'\G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: B
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 301036
        Extra: Using where
*************************** 2. row ***************************
           id: 2
  select_type: DEPENDENT SUBQUERY
        table: A
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 10000000
        Extra: Using where
2 rows in set (0.01 sec)

MariaDB [TestOpt]> 

The server starts with a table-scan on the table TestSmalll and in the case of a record matching the WHERE-condition it starts another query on the table TestBig. This query can only be done by a table-scan as no index on this table exists. This explains the time needed for the execution of this statement.


some calculations

Let's check the data and see how many records are involved. So here are the number of records in the table TestSmall that match the (partial) WHERE-condition:
MariaDB [TestOpt]>MariaDB [TestOpt]> select count(*) from TestSmall where Hersteller = '00020';
+----------+
| count(*) |
+----------+
|       60 |
+----------+
1 row in set (0.39 sec)

MariaDB [TestOpt]>
There are 301.036 records in the table TestSmall1) and 60 of these match the (partial) WHERE-condition of the SQL-statement above.

And here is the data for the table TestBig:
MariaDB [TestOpt]>MariaDB [TestOpt]> select count(*) from TestBig where Hersteller = '36367';
+----------+
| count(*) |
+----------+
|   461016 |
+----------+
1 row in set (12.40 sec)

MariaDB [TestOpt]> 

Let's look at the first number: by executing the statement above 60 records in TestSmall match the WHERE-condition. When such a record is found the database-server has to switch to the table TestBig and start reading this table in a table-scan, in the worst case when no matching record is found in TestBig it hast to read this table to the end.

In an older post I introduced some counter-variables to count the number of times the function ha_myisam::rnd_next() is called. This is what these counters tell me about the statement shown above:
table = <TestBig>   counter table-scan: 573354474   index-scan: 0
table = <TestSmall> counter table-scan: 301037  index-scan: 0

As there are 301.036 records in the table TestSmall this table is indeed read once (add 1 for detecting the EOF-situation) but what about TestBig? When the first record matching the condition is found in TestBig the table-scan stops so there are less than 60*10mio. read-operations. Let's verify this: I added this text to the subselect-part of the statement above: and Id = 1. As there is no value 1 in the Id-column this part is always false and the server has to read the full table. For this statement it tells at the end:
table = <TestBig> counter table-scan: 600000060 index-scan: 0
table = <TestSmall> counter table-scan: 301037 index-scan: 0

As the table TestBig contains 10 mio. records (add 1 for the EOF) you can see that this table is indeed read 60 times.

Everything seems clear now. But wait: what about the 25 minutes?

Let's omit the time reading the table TestSmall, one third of a second is a tiny fraction of the 25 minutes the whole process takes, so let's only look at the time for reading TestBig. A full table-scan on this table takes about 12 seconds as shown some lines above. As this is done 60 times I would expect the whole process to take about 12 minutes but it does take twice as long. Why this?


into the code

For inspecting this situation I modified the code in ha_myisam.cc again:
  • added a global variable: time_t timeStart;
  • in ha_myisam::rnd_init() initialize the variable: timeStart = time( NULL );
  • in ha_myisam::rnd_end() there was already a line of code for outputting the value of the counter-variables. I modified this line to output the time too:
    fprintf(stderr, "\ntable = <%s>\tcounter\ttable-scan: %d\tindex-scan: %d\ttime = %ld\n", table_share->table_name.str, counterTableScan, counterIndexScan, time(NULL)-timeStart);
For measuring the time used for a table-scan in this environment it's sufficient to work with a precision of 1 second so the function time() may help here.

And here is the result of the next run of my statement:
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 12
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 26
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 27
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 27
......
As you can see the first scan is fast, as expected, but all other scans are slow. Why this?

By stepping into the code I found this function:
int _mi_read_cache(IO_CACHE *info, uchar *buff, my_off_t pos, uint length,
     int flag)
{
  ....
  if (pos < info->pos_in_file)
  {
    .....
    if (mysql_file_pread(info->file, buff, read_length, pos, MYF(MY_NABP)))
      DBUG_RETURN(1);
    if (!(length-=read_length))
      DBUG_RETURN(0);
    ....
  }
  DBUG_RETURN(0);
} /* _mi_read_cache */

As described in my older text the data is read from the file in a block of 128K bytes. The variable info->pos_in_file points to the position in the file containing the beginning of the bytes currently in the block (=buffer). When a record is read the bytes are copied from the (global) buffer into the local buffer - simplified. When the global buffer is at the end the next block of 128K bytes will be read from the file. At the end of the table-scan the global buffer contains the last bytes of the file. When the next table-scan starts from the beginning of the file this information will not be reset so the if()-statement is true in most cases because the bytes from the beginning of the table are requested. So the code following the if()-statement will be executed: the data will be read from the disk directly by calling the read()-function of the Std-library. As there are 10 mio. records in this table it means millions of calls of this function2). And that's slow compared to copying the bytes from the buffer.

How can this be fixed?


a quick and dirty fix

By looking into the code I found a function reinit_io_cache() which looks promising So I added a call to this function to the code. Where do I add this call? The solution I will present here is quick and dirty:
  • it's quick: it can be implemented and tested in minutes
  • it's dirty: I added it in the wrong place (I'm sure about this). And it's specific for my test-case.
Nevertheless it's a test only so I did this:
in ha_myisam.cc I added these lines:
  • a global variable: bool is1stScan = true;
  • in the function ha_myisam::rnd_init() I added these lines:
     counterTableScan = counterIndexScan = 0;
     char *tableName = table_share->path.str;
     if ( 0 == strcasecmp(tableName, "./TestOpt/TestBig") )
     {
         if ( !is1stScan )
             reinit_io_cache(&file->rec_cache,READ_CACHE,0L,0,0);
         is1stScan = false;    // for the following calls
     }
    

So recompiling and starting the server and in the next test this result showed up:
MariaDB [TestOpt]> select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestSmall B where exists ( select 1 from TestBig A where A.Hersteller = '36367' and A.PZN = B.PZN and Id = 1) and B.Hersteller = '00020';
Empty set (13 min 22.58 sec)

MariaDB [TestOpt]> 

That looks better. Please keep in mind that this is he statement that enforces a table-scan up to the end (I've marked that part of the statement in bold).

And here are the timing-values of this execution:
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 13
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 12
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 13
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 12
table = <TestBig> counter table-scan: 10000001 index-scan: 0 time = 12
.......
Please compare this to the values presented above: that looks good. I modified the SQL-statement back to the original one and let it run: it took about 11 minutes and 40 seconds, as expected.


That's it for today.



changing the order of the tables

What do you think about changing the place of the tables meaning putting the table TestSmall into the subquery and the table TestBig outside? So the execution will start whith a table-scan on the TestBig and read the table TestSmall only when a match is found? Loks like this:
MariaDB [TestOpt]> select SQL_NO_CACHE  B.PZN, B.ArtikelText  from TestBig B where exists ( select 1 from TestSmall A where A.Hersteller = '00020' and A.PZN = B.PZN and Id = 1) and B.Hersteller = '36367';
^CCtrl-C -- query killed. Continuing normally.
ERROR 1317 (70100): Query execution was interrupted
MariaDB [TestOpt]> 

Forget about this statement. Let's do some quick calculations: it takes about 1/3 of a second to scan the table TestSmall. As there are 460.000 records in TestBig matching the WHERE-condition and for each record found the table TestSmall is scanned so it takes about 460.000/3 seconds (approx. 42 hours), so as I've already written forget about this.




correctness

This text is a simplified presentation of what's going on in the software. As I'm still learning the inner-workings of this software errors on my side can happen. In case something is wrong with my description please use the mail-function of this site and send me a mail describing my error. I will look at it. Thanks.




some notes:
1) if you look into the explain you will find this number
2) approx. 20 mio. calls as in the case of MyISAM reading a record means at least 2 calls

Wednesday, 7 December 2016

subselect: execution (2)


in my last post (subselect: execution) I played with a special SQL-statement and showed how this statement is treated in the execution-stage of the server. For this text I simply want to change the order of the tables within this statement and look what's happening then. Most of this text looks at the code of MariaDB, in the end I will describe the difference to MySQL.


warning

Before I dive into the code let me issue a warning: the functions, hierarchies and data-examples shown here are valid for the environment on my PC. If you do your own tests this may look a bit different.


environment

Again I'm using my usual environment which I already described in one of my older posts. It's the same tables, the same data in the tables etc.. I've changed the SQL-statement and therefore my focus changes.


the SQL-statement

For this text this is the statement I want to inspect:
MariaDB [TestOpt]> select SQL_NO_CACHE  A.PZN, A.ArtikelText  from TestSmall A where A.PZN in ( select B.PZN from TestBig B where B.Hersteller = '36367') and A.Hersteller = '00020';
+--------+----------------------------+
| PZN    | ArtikelText                |
+--------+----------------------------+
| 178324 | TINY TOON ADVENTURES KIND  |
| 87886  | MONDOS KONDOME COLOR       |
| 87923  | MONDOS KONDOME NATURE      |
| 87952  | MONDOS KONDOME XXL         |
+--------+----------------------------+
4 rows in set (17.84 sec)

MariaDB [TestOpt]>

Let me add the explain the server returns:
MariaDB [TestOpt]> explain select SQL_NO_CACHE  A.PZN, A.ArtikelText  from TestSmall A where A.PZN in ( select B.PZN from TestBig B where B.Hersteller = '36367') and A.Hersteller = '00020'\G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: A
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 301036
        Extra: Using where
*************************** 2. row ***************************
           id: 1
  select_type: PRIMARY
        table: <subquery2>
         type: eq_ref
possible_keys: distinct_key
          key: distinct_key
      key_len: 7
          ref: func
         rows: 1
        Extra: 
*************************** 3. row ***************************
           id: 2
  select_type: MATERIALIZED
        table: B
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 10000000
        Extra: Using where
3 rows in set (0.02 sec)

MariaDB [TestOpt]>

As you can see the statement looks very similar to the statement in my last post. Again it's a statement with a subselect. If you look at it a bit closer you will see that the order of the tables changed: now the table TestBig is in the subselect (=subquery) and TestSmall is in the outer part of the query. Also this query takes about 10% more time than the old query; this can be due to the fact that the subselect now returns much more rows (about 400K) than the old subselect (about 60). And also this query returns only 4 rows, the old one returned 14 rows.

Why do we now have only 4 rows returned? Let's look at the result of the old query again:
MariaDB [TestOpt]> select SQL_NO_CACHE  A.PZN, A.ArtikelText from TestBig A where A.PZN in ( select B.PZN from TestSmall B where B.Hersteller = '00020') and A.Hersteller = '36367';
+--------+----------------------------+
| PZN    | ArtikelText                |
+--------+----------------------------+
| 178324 | YPSIFLEX 10CMX4M EL FIXIER |
| 178324 | STIBIUM SULF AURANT D 6    |
| 87886  | GILOFA BAS 40 KNSTR 1KANDI |
| 178324 | SOFTCLIX II LANZETTEN      |
| 87952  | RUTIVISCAL N               |
| 178324 | CROTAMITON                 |
| 87952  | NOBASTRETCH KRAEFT 7MX6CM  |
| 87952  | STECHBECKEN 31CM STAHL     |
| 87923  | LIPP LOVE LUXUS SUN PROT12 |
| 87952  | TASTATURHILFE UNT NC21017  |
| 178324 | HOLLISTER KOL KLEBE F 2178 |
| 87952  | MEDI TEST GLUCOSE          |
| 178324 | ESTOSAN TUBE               |
| 87952  | FRUCHTS DUSCHGEL ORANGE    |
+--------+----------------------------+
14 rows in set (17.34 sec)

MariaDB [TestOpt]> 

If you look a bit closer at the result you will see duplicate values in the column PZN. This statements verifies this:
MariaDB [TestOpt]> select SQL_NO_CACHE  distinct A.PZN from TestBig A where A.PZN in ( select B.PZN from TestSmall B where B.Hersteller = '00020') and A.Hersteller = '36367';
+--------+
| PZN    |
+--------+
| 178324 |
| 87886  |
| 87952  |
| 87923  |
+--------+
4 rows in set (15.72 sec)

MariaDB [TestOpt]> 

So there are multiple records with identical value in the column PZN in TestBig, but not in the table TestSmall. This explains the different results.

So this statement is treated identical to the one described in the last text? Yes. Here is a description of the engine used for the temp-table created internally: MEMORY Storage Engine. But one term caught my interest: max_heap_table_size. So what will happen when we the size of the table will exceed this value?

From the documentation given we know that this variable can contain a value between 16 MB and 4.294.966.272 (= 4GB - 1KB). So let's play with this value.


playing with max_heap_table_size

Let's start with the fefault-value:
MariaDB [TestOpt]> select @@max_heap_table_size;
+-----------------------+
| @@max_heap_table_size |
+-----------------------+
|              16777216 |
+-----------------------+
1 row in set (0.00 sec)

MariaDB [TestOpt]> select SQL_NO_CACHE  A.PZN, A.ArtikelText  from TestSmall A where A.PZN in ( select B.PZN from TestBig B where B.Hersteller = '36367') and A.Hersteller = '00020';
+--------+----------------------------+
| PZN    | ArtikelText                |
+--------+----------------------------+
| 178324 | TINY TOON ADVENTURES KIND  |
| 87886  | MONDOS KONDOME COLOR       |
| 87923  | MONDOS KONDOME NATURE      |
| 87952  | MONDOS KONDOME XXL         |
+--------+----------------------------+
4 rows in set (16.66 sec)

MariaDB [TestOpt]>

As you can see the variable has a value of 16 MB. Let's try to set this to a minimal value:
MariaDB [TestOpt]> set max_heap_table_size = 1024;
Query OK, 0 rows affected, 1 warning (0.00 sec)

MariaDB [TestOpt]> select @@max_heap_table_size;
+-----------------------+
| @@max_heap_table_size |
+-----------------------+
|                 16384 |
+-----------------------+
1 row in set (0.00 sec)

MariaDB [TestOpt]> select SQL_NO_CACHE  A.PZN, A.ArtikelText  from TestSmall A where A.PZN in ( select B.PZN from TestBig B where B.Hersteller = '36367') and A.Hersteller = '00020';
+--------+----------------------------+
| PZN    | ArtikelText                |
+--------+----------------------------+
| 178324 | TINY TOON ADVENTURES KIND  |
| 87886  | MONDOS KONDOME COLOR       |
| 87923  | MONDOS KONDOME NATURE      |
| 87952  | MONDOS KONDOME XXL         |
+--------+----------------------------+
4 rows in set (35.47 sec)

MariaDB [TestOpt]>

Something changes now. I tried to set the value of the variable max_heap_table_size to a minimal value but the server sets the value to 16 KB. And the execution of the statement takes a lot more of time, it more then doubles.

So why does this happen? And wehre does it happen?

Let's dive into the code.


identical behaviour

In the beginning the behaviour of the server is identical to the last example, so here is only a small reminder of the hierarchy for reading the first record from the table TestSmall1):
mysql_select()
    JOIN::exec()
        JOIN::exec_inner()
            do_select()
                sub_select()
                    join_init_read_record()           called via (*join_tab->read_first_record)()
                        rr_sequential()
                    evaluate_join_record()

As (in my case) evaluate_join_record() did not find a match the server goes to the main loop and reads record after record from TestBig and inspects each one:
sub_select()
    rr_sequential()       called via info->read_record()
    evaluate_join_record()

When evaluate_join_record() finds a matching record from TestSmall it acts as described in my last text: the server switches from level 1 (=reading from TestSmall) to level 2 (=handling the temp-table) to level 3 (= reading from TestBig and putting the data found into the temp-table). On this level the hierarchy looks like this:
sub_select()                                level 3
    evaluate_join_record()
        end_sj_materialize()                called via (*join_tab->next_select)()
            fill_record()
                Item_field::save_in_field()
                    save_field_in_field()
            handler::ha_write_tmp_row()
                ha_heap::write_row()

All the same, as before.


new behaviour

With the SQL-statement shown some lines above I've set the size of a table of type MEMORY to max. 16K bytes, and now things will change.

As there are approx. 400K records in TestBig that fulfill the WHERE-condition and therefore have to be put into the temp-table the size of this table will be at least approx. 2.4 MB (=400K records * 6 bytes for the PZN-value) which exceeds the new give max. value for this table. So let's look how this situation is detected and handled.

Somewhere in middle of the execution I looked into the /temp-directory and found this:
august@AMD4:~/MariaDB/pgm$ ls -l /tmp
insgesamt 36
....
-rw-rw---- 1 august august  4104 Nov 30 16:31 #sql_243a_0.MAD
-rw-rw---- 1 august august 16384 Nov 30 16:31 #sql_243a_0.MAI
....
august@AMD4:~/MariaDB/pgm$ 

As you can see 2 files are created with the name of the temp-table plus the extension MAD- and MAI. By looking into the MAD-file I found this:
august@AMD4:~/MariaDB/pgm$ od -t x1 -Ax /tmp/#sql_243a_0.MAD
000000 ff 35 33 35 38 31 38 20 ff 39 39 39 39 39 39 20       '535818 ' '999999 '
000010 ff 32 30 34 36 36 35 20 ff 35 31 33 35 37 33 20       '204665 ' '513573 '
000020 ff 33 38 32 39 37 30 20 ff 32 31 37 38 35 37 20       '382970 ' '217857 '
and so on

This looks like the data-file of the Maria-engine (this contains a mode almost identical to MyIsam2)) and the contents shown are the first values of the column PZN from the table TestBigl. And the MAI-file looked like the corresponding index-file. So where does this happen in the code?

It can only happen when a new record is added to the temp-table and with this record the temp-table exceeds some given limit. Let's look over there:
sub_select()                      we are at level 3
    evaluate_join_record()
        end_sj_materialize()
            handler::ha_write_tmp_row()
                ha_heap::write_row()
                    heap_write()
                        next_free_record_pos()

In the last function given in this hierarchy this situation is detected:
  if (!(block_pos=(info->records % info->block.records_in_block)))
  {
    if ((info->records > info->max_records && info->max_records) ||
        (info->data_length + info->index_length >= info->max_table_size))
    {
      ....
      my_errno=HA_ERR_RECORD_FILE_FULL;           = 135
      DBUG_RETURN(NULL);
    }
    ....
  }

In the case I inspected the var. info->max_records contained a value of 512 (more on this later), trying to insert the next records results in the function returning NULL. So the code marches the hierarchy up to the function end_sj_materialize() to the point where the call started. Here the code looks like this:
    if ((error= table->file->ha_write_tmp_row(table->record[0])))
    {
      /* create_myisam_from_heap will generate error if needed */
      if (table->file->is_fatal_error(error, HA_CHECK_DUP) &&
          create_internal_tmp_table_from_heap(thd, table,
                                              sjm->sjm_table_param.start_recinfo, 
                                              &sjm->sjm_table_param.recinfo, error, 1, NULL))
        DBUG_RETURN(NESTED_LOOP_ERROR); /* purecov: inspected */
    }

Normally the function ha_write_tmp_row() returns 0 (when everything is OK) or HA_ERR_FOUND_DUPP_KEY (when the value is already in the temp-table) but in the case of HA_ERR_RECORD_FILE_FULL the contents of the temp-table is written on the disk. If you look at the information some lines above you will see that an entry in the MAD-file is 8 bytes long. So with 513 records the size of the temp-file on disk is 4104 bytes.

From this moment on the temp-table is no longer of type MEMORY but of type MARIA, the table is no longer stored in RAM but is stored on disk and all reading and writing to the temp-table is handled with the disk-based version of the file (or with the cache of this file). You will loose the speed-advantage of the RAM-based table but the handling of the statement continues. The situation of overflowing the size-limit for the temp-table in RAM is detected and the server silently switches to a disk-based solution and continues.

This explains the increase in execution-time needed when the max. size of the RAM-based HEAP-table is changed to a minimal value.

Where does the value 512 for the switch comes from? You will find the code in heap_prepare_hp_create_info():
    case HA_KEY_ALG_HASH:
      keydef[key].algorithm= HA_KEY_ALG_HASH;
      mem_per_row+= sizeof(char*) * 2; // = sizeof(HASH_INFO)               result = 16
      break;
....
  mem_per_row+= MY_ALIGN(share->reclength + 1, sizeof(char*));              result = 32
....
  max_rows= (ha_rows) (hp_create_info->max_table_size / mem_per_row);       result = 512
....
  hp_create_info->max_records= (ulong) MY_MIN(max_rows, ULONG_MAX);         result = 512

In the beginning of the execution of this SQL-statement the value for max_records is computed (an estimation) and set.

Here my journey ends for today.


what about MySQL?

No, it's not finished yet. MySQL acts very similar but it creates a file on disk as a MyISAM-file. Here you can see the differences in the code:
MySQL3) MariaDB4)
in end_sj_materialize():
    if ((error= table->file->ha_write_row(table->record[0])))
    {
      /* create_myisam_from_heap will generate error if needed */
      if (table->file->is_fatal_error(error, HA_CHECK_DUP) &&
          create_myisam_from_heap(thd, table,
                                  sjm->table_param.start_recinfo, 
                                  &sjm->table_param.recinfo, error,
                                  TRUE, NULL))
        DBUG_RETURN(NESTED_LOOP_ERROR); /* purecov: inspected */
    }
in end_sj_materialize():
    if ((error= table->file->ha_write_tmp_row(table->record[0])))
    {
      /* create_myisam_from_heap will generate error if needed */
      if (table->file->is_fatal_error(error, HA_CHECK_DUP) &&
          create_internal_tmp_table_from_heap(thd, table,
                                              sjm->sjm_table_param.start_recinfo, 
                                              &sjm->sjm_table_param.recinfo, error, 1, NULL))
        DBUG_RETURN(NESTED_LOOP_ERROR); /* purecov: inspected */
    }
/**
  If a MEMORY table gets full, create a disk-based table and copy all rows
  to this.

......
*/

bool create_myisam_from_heap(THD *thd, TABLE *table,
                             MI_COLUMNDEF *start_recinfo,
                             MI_COLUMNDEF **recinfo, 
        int error, bool ignore_last_dup,
                             bool *is_duplicate)

/*
  If a HEAP table gets full, create a internal table in MyISAM or Maria
  and copy all rows to this
*/


bool
create_internal_tmp_table_from_heap(THD *thd, TABLE *table,
                                    TMP_ENGINE_COLUMNDEF *start_recinfo,
                                    TMP_ENGINE_COLUMNDEF **recinfo, 
                                    int error,
                                    bool ignore_last_dupp_key_error,
                                    bool *is_duplicate)


And that's it for today.




correctness

This text is a simplified presentation of what's going on in the software. As I'm still learning the inner-workings of this software errors on my side can happen. In case something is wrong with my description please use the mail-function of this site and send me a mail describing my error. I will look at it. Thanks.




some notes:
1) in the last text the reading starts with the table TestBig, in this case it starts with reading from the table TestSmall because this is the table in the outer past of the statement
2) I already described this here: od
3) MySQL vdersion 5.6.22
4) MariaDB version 10.0.10

Tuesday, 1 November 2016

subselect: execution


Some weeks ago I wanted to leave this topic and look at other parts of the code but I found some queries which looked interesting and therefore I stayed on my route. So I let me start this text with my usual introductory words.

warning

Before I dive into the code let me issue a warning: the functions, hierarchies and data-examples shown here are valid for the environment on my PC. If you do your own tests this may look a bit different.

environment

Again I'm using my usual environment which I already described in one of my older posts. It's the same tables, the same data in the tables etc.. I've changed the SQL-statement and therefore my focus changes.

the SQL-statement

So here is my statement which I want to inspect and describe my results here in this text:
MariaDB [TestOpt]> select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestBig B where B.PZN in ( select A.PZN from TestSmall A where A.Hersteller = '00020') and B.Hersteller = '36367';
+--------+----------------------------+
| PZN    | ArtikelText                |
+--------+----------------------------+
| 178324 | YPSIFLEX 10CMX4M EL FIXIER |
| 178324 | STIBIUM SULF AURANT D 6    |
| 87886  | GILOFA BAS 40 KNSTR 1KANDI |
| 178324 | SOFTCLIX II LANZETTEN      |
| 87952  | RUTIVISCAL N               |
| 178324 | CROTAMITON                 |
| 87952  | NOBASTRETCH KRAEFT 7MX6CM  |
| 87952  | STECHBECKEN 31CM STAHL     |
| 87923  | LIPP LOVE LUXUS SUN PROT12 |
| 87952  | TASTATURHILFE UNT NC21017  |
| 178324 | HOLLISTER KOL KLEBE F 2178 |
| 87952  | MEDI TEST GLUCOSE          |
| 178324 | ESTOSAN TUBE               |
| 87952  | FRUCHTS DUSCHGEL ORANGE    |
+--------+----------------------------+
14 rows in set (16.24 sec)

MariaDB [TestOpt]>

As you can see I'm using a subselect now and I'm interested in how the whole statement and especially the subselect is treated in the execution-stage of MariaDB1).

environment

Just one more repetition: this is how the 2 tables look like:
MariaDB [TestOpt]> desc TestSmall;
+-------------+--------------+------+-----+---------+-------+
| Field       | Type         | Null | Key | Default | Extra |
+-------------+--------------+------+-----+---------+-------+
| Id          | char(8)      | YES  |     | NULL    |       |
| PZN         | char(7)      | YES  |     | NULL    |       |
| EVP         | decimal(7,2) | YES  |     | NULL    |       |
| HAP         | decimal(7,2) | YES  |     | NULL    |       |
| ArtikelBez  | varchar(40)  | YES  |     | NULL    |       |
| ArtikelText | varchar(26)  | YES  |     | NULL    |       |
| Hersteller  | char(5)      | YES  |     | NULL    |       |
+-------------+--------------+------+-----+---------+-------+
7 rows in set (0.00 sec)

MariaDB [TestOpt]>

The table TestBig has an identical structure; TestSmall contains 301,036 records, TestBig contains 10 mio. records. The data is from an old project but the contents of the data is not of interest here.

There are no indexes defined on these tables. Silly, I know, but the database-server has to handle this situation.

explain

Let's look at what the server tells us how it handles the statement:
MariaDB [TestOpt]> explain select SQL_NO_CACHE   B.PZN, B.ArtikelText  from TestBig B where B.PZN in ( select A.PZN from TestSmall A where A.Hersteller = '00020') and B.Hersteller = '36367'\G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: B
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 10000000
        Extra: Using where
*************************** 2. row ***************************
           id: 1
  select_type: PRIMARY
        table: <subquery2>
         type: eq_ref
possible_keys: distinct_key
          key: distinct_key
      key_len: 7
          ref: func
         rows: 1
        Extra: 
*************************** 3. row ***************************
           id: 2
  select_type: MATERIALIZED
        table: A
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 301036
        Extra: Using where
3 rows in set (0.00 sec)
MariaDB [TestOpt]> 

Oh, this looks different to the explains of the older examples. As each table is represented internally by an object of type JOIN_TAB we see that we have 3 JOIN_TABs in our example instead of the 2 objects in the older posts. So let's look into this situation and describe the results.


following the code

Before I jump into the code let me show you some numbers, they show what we have to expect.

some numbers

Let's split the SQL-statement into parts and look at the numbers these partial statements deliver:
MariaDB [TestOpt]> select A.PZN from TestSmall A where A.Hersteller = '00020';
+---------+
| PZN     |
+---------+
| 178324  |
| 3717968 |
| 3717974 |
..............
| 8425779 |
| 8425785 |
| 8494562 |
| 8494579 |
+---------+
60 rows in set (0.37 sec)

MariaDB [TestOpt]> 
There are 60 records in TestSmall that match the WHERE-condition, so the inner SQL-statement (=the subselect) will deliver 60 records.

MariaDB [TestOpt]> select b.pzn from TestBig b where b.hersteller = '36367';
+--------+
| pzn    |
+--------+
| 535818 |
| 999999 |
| 999999 |
..............
| 19057  |
| 999999 |
| 999999 |
| 999999 |
| 999999 |
| 999999 |
| 999999 |
| 999999 |
+--------+
461016 rows in set (34.24 sec)

MariaDB [TestOpt]> 
More then 400,000 records from TestBig will match the WHERE-condition and have to be matched for the IN-condition. A bit of work, this will take some time.

Let's look at how the server handles this statement.

overview

The function-hierarchy for the execution of this statement looks like this (simplified):
JOIN::exec()
    JOIN::exec_inner()
        do_select()
            sub_select()                                  level 1
                evaluate_join_record()
                    sub_select()                          level 2
                        join_tab_execution_startup()
                            sub_select()                  level 3
                                evaluate_join_record()
The server starts executing this statement by doing a table-scan on the table TestBig. When the first record matching the condition is found, it starts with the handling of a temp-file. In this step it reads the table TestSmall and putts the PZNs of the matching records into it. When this is done it checks if the PZN-value of a record from TestBig can be found in the temp-file and when found acts accordingly.

That's all.

The function sub_select() will appear multiple times in my text here so to avoid confusion let me introduce some conventions: I will write of level 1 when sub_select() handles the table TestBig, of level 2 when the temp-table is handled and of level 3 when the table TestSmall is handled. I hope this clears my text a bit.


jumping into the code

And now, really, I want to jump into the code. Let's start with the first call of the function sub_select().

level 1

When sub_select() is called for the first time it is given a JOIN_TAB representing the table TestBig (as expected from the explain). In this function it reads and inspects the first record from this table:
  if (rc != NESTED_LOOP_NO_MORE_ROWS)
  {
    error= (*join_tab->read_first_record)(join_tab); 
    ....
    rc= evaluate_join_record(join, join_tab, error);
  }
read_first_record() calls join_init_read_record() which calls rr_sequential() via the statement (*tab->read_record.read_record). So the first record from TestBig is read by this code. evaluate_join_record() inspects this records but in my case it didn't find a match.

So it continues with the main loop:
  while (rc == NESTED_LOOP_OK && join->return_tab >= join_tab)
  {
    .....
    error= info->read_record(info);
    ....
    rc= evaluate_join_record(join, join_tab, error);
  }
info->read_record() calls rr_sequential() which reads the next record from TestBig, evaluate_join_record() inspects the record read. When evaluate_join_record() finds a match this code is executed next:
in evaluate_join_record():
  if (select_cond)
  {
    select_cond_result= MY_TEST(select_cond->val_int());        applies the (partial) WHERE-condition
    .....
  }
  ....
    if (found)
    {
      ....
      /* A match from join_tab is found for the current partial join. */
      rc= (*join_tab->next_select)(join, join_tab+1, 0);  

level 2

This last line shown calls sub_select() with a JOIN_TAB for a table named /tmp/#sql_247d_02) of type MEMORY (in older versions this type was called HEAP). So the server is now at level 2 and here is what the server does with this empty table in sub_select() in this level:
in sub_select():
  if (rc != NESTED_LOOP_NO_MORE_ROWS && 
      (rc= join_tab_execution_startup(join_tab)) < 0)
    DBUG_RETURN(rc);
The code prepares for switching to the 3rd level:
 in join_tab_execution_startup():
  .......
  else if (tab->bush_children)
  {
    /* It's a merged SJM nest */
    .....
      /*
        Now run the join for the inner tables. The first call is to run the
        join, the second one is to signal EOF (this is essential for some
        join strategies, e.g. it will make join buffering flush the records)
      */
      if ((rc= sub_select(join, join_tab, FALSE/* no EOF */)) < 0 ||
          (rc= sub_select(join, join_tab, TRUE/* now EOF */)) < 0)
      {
        ....
      }

level 3

As you can see sub_select() is called again, this time with JOIN_TAB representing the table TestSmall. It is called twice, the second time for clean-up-operations.

Let's look at what the first call of sub_select() does: similar steps as described for TestBig some lines above are done so it reads the first record from TestSmall via (*join_tab->read_first_record)() and inspects it in evaluate_join_record((). As no match is found in my case it continues with the main loop, again as described some lines above for the table TestBig. Interesting things happen again when a match is found:
in evaluate_join_record():
      /* A match from join_tab is found for the current partial join. *
      rc= (*join_tab->next_select)(join, join_tab+1, 0); 
with this call in the last line a lot of things happen here:
evaluate_join_record()
    end_sj_materialize()                      via (*join_tab->next_select)()                  
        fill_record()
            Item_field::save_in_field()
                save_field_in_field()
        handler::ha_write_tmp_row()
            ha_heap::write_row()
                heap_write()
As the server is currently reading the table TestSmall and there are 60 records matching the condition so there are 60 values of PZN taken from these records and stored into this temp-table.
At the end of the table-scan of TestSmall the function sub_select() is left with a return-value of NESTED_LOOP_OK and called immediately again with a value TRUE for the parameter end_of_records, called from join_tab_execution_startup():
      if ((rc= sub_select(join, join_tab, FALSE/* no EOF */)) < 0 ||
          (rc= sub_select(join, join_tab, TRUE/* now EOF */)) < 0)
In this clean-up-operation there is not so much that happens:
join_tab_execution_startup()  
    sub_select()
        end_sj_materialize
So it returns to to the calling function soon.

back to level 2

Now we're back at sub_select(), the table TestSmall is already read and the temp-table is filled with the records from TestSmall.


a short break

OK, a short break and I want to take the time to remember what the server has done already.

We've read record after record from TestBig until we found the first record which matches the (partial) condition. We keep the PZN-value from this record, switch over to a temp-table and read the records from TestSmall, check these records and if a records matches the (partial) condition on TestSmall we put the PZN-value of this record (from TestSmall) into the temp-table.

And I've stopped at this step.

The next step will be to continue reading and inspecting the records from TestBig. If we found a matching record we will look for the PZN-value in the temp-table. As we already have a matching record read from TestBig so the server will immediately start with the lookup in the temp-table.

So we continue our journey at level 2.


continuing at level 2

Now we try to find the PZN-value (from the record from TestBig) in our temp-table.
sub_select()
    join_read_key()               via (*join_tab->read_first_record)(join_tab);
        join_read_key2()
            handler::ha_index_read_map()
                ha_heap::index_read_map()
In my case it did not find this value in the index so it returns the value HA_ERR_KEY_NOT_FOUND (converted to -1). Next evaluate_join_record() is called which checks the error-code and returns NESTED_LOOP_NO_MORE_ROWS. For this reason sub_select() is left and we're back in evaluate_join_record() of level 1.

back to level 1

So in sub_select() we're in the main-loop again, reading and evaluating record after record from TestBig (already described some lines above).

In evaluate_join_record() the record read is inspected and if it does not match the condition it returns. This cycle, reading and inspecting, continues until a match is found. In this case:
sub_select()                                   level 1
    evaluate_join_record()                     calls the next level via (*join_tab->next_select)(join, join_tab+1, 0);
        sub_select                             level 2   
            join_read_key()                    by reading the first record, via (*join_tab->read_first_record)(join_tab);
                join_read_key2()
                    handler::ha_index_read_map()
                        ha_heap::index_read_map()
so the server switches to level 2 and at this level in sub_select() it reaches the code for reading a first record from the temp-table using the PZN-value of the current record from TestBig as a key to look for (in the temp-table) in JOIN_TAB->ref->key_buff.

So now the steps are: read records from TestBig (in level 1). When a match is found in evaluate_join_record() the server switches to level 2 and look if the PZN-value can be found in the temp-table. These steps continues until all records from TestBig are read and treated.


I hope you did follow this text until here, it was a bit of work understanding and describing these steps.


correctness

This text is a simplified presentation of what's going on in the software. As I'm still learning the inner-workings of this software errors on my side can happen. In case something is wrong with my description please use the mail-function of this site and send me a mail describing my error. I will look at it. Thanks.



some notes:
1) on MySQL explain also delivers 3 JOIN_TABs, but they look differently
2) did I mention that this may look differently on your machine?

Thursday, 28 July 2016

the real JOIN: a different look at the case without any index


Please allow me to continue to play with my usual statement in my usual environment. I like to modify the statement a little bit and show you how much changed in executing the modified statement.

In this text I will often refer to something "old" like the "old statement" or the "old text". This always means this text (or something you can find in it): the real JOIN: the case without indexes. I will not set any link to this text in the text here, please keep this link in your mind.


warning

Before I dive into the code let me to issue a warning: the functions, hierarchies and data-examples shown here are valid for the environment on my PC. If you do your own tests this may look a bit different.


environment

Again I'm using my usual environment which I already described in one of my older posts. It's the same SQL-statement, the same tables, the same data in the tables etc.. Only my focus changes.


the old situation

In my old text I looked at this statement:
MariaDB [TestOpt]> select SQL_NO_CACHE  count(A.PZN) from TestSmall A join TestBig B IGNORE INDEX (PZN) on (A.PZN = B.PZN) where A.Hersteller = '00020' and B.Hersteller = '36367';
+--------------+
| count(A.PZN) |
+--------------+
|           14 |
+--------------+
1 row in set (23.03 sec)

MariaDB [TestOpt]> explain select SQL_NO_CACHE  count(A.PZN) from TestSmall A join TestBig B IGNORE INDEX (PZN) on (A.PZN = B.PZN) where A.Hersteller = '00020' and B.Hersteller = '36367'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: A
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 301036
        Extra: Using where
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: B
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 10000000
        Extra: Using where; Using join buffer (flat, BNL join)
2 rows in set (34.99 sec)

MariaDB [TestOpt]> 

The explain tells us that the execution starts with reading the table TestSmall. After this is done the table TestBig is read. Diving into the code showed that by reading the table TestSmall and applying the WHERE-clause on a record just read, a record matching the condition is put into a buffer. When reading this table is finished the server starts reading the records from the table TestBig, applying the WHERE-clause and when a match is found it does the JOIN by looking through the buffer to find a record (from TestSmall) with the same value of PZN.


the new situation

For this text here I wan to change the order of the execution, first TestBig and then TestSmall, and see if and what's changing in the execution of this statement. Here is the statement and the explain:
MariaDB [TestOpt]> select SQL_NO_CACHE  count(A.PZN) from TestBig A IGNORE INDEX(PZN) straight_join TestSmall B  on (A.PZN = B.PZN) where B.Hersteller = '00020' and A.Hersteller = '36367';
+--------------+
| count(A.PZN) |
+--------------+
|           14 |
+--------------+
1 row in set (1 min 6.82 sec)

MariaDB [TestOpt]> explain select SQL_NO_CACHE  count(A.PZN) from TestBig A IGNORE INDEX(PZN) straight_join TestSmall B  on (A.PZN = B.PZN) where B.Hersteller = '00020' and A.Hersteller = '36367'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: A
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 10000000
        Extra: Using where
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: B
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 301036
        Extra: Using where; Using join buffer (flat, BNL join)
2 rows in set (0.00 sec)

MariaDB [TestOpt]>

As you can see there is a difference: the new statement took 66 seconds vs. 23 seconds of the old statement: it took nearly as 3times as long. Why? The purpose of this text is to show you the reason of this.

Please look at the statement more closely: I've used the keyword straight_join instead of join and I've changed the order of the tables in the statement. Changing the order of the tables without using the keyword straight_join the optimizer would have chosen the same plan for the execution as before and I could not present this text to you.


some numbers

I want to show you some numbers from the data.

First of all TestSmall contains 301,036 records, TestBig contains 10 mio. records, which you can verify if you look into the explain-lines above.

In TestSmall and TestBig only a small fraction of the records fulfill the (partial) WHERE-condition:
MariaDB [TestOpt]> select SQL_NO_CACHE  count(A.PZN) from TestSmall A where A.Hersteller = '00020';
+--------------+
| count(A.PZN) |
+--------------+
|           60 |
+--------------+
1 row in set (0.44 sec)

MariaDB [TestOpt]> select SQL_NO_CACHE  count(B.PZN) from TestBig B where B.Hersteller = '36367';
+--------------+
| count(B.PZN) |
+--------------+
|       461016 |
+--------------+
1 row in set (12.78 sec)

MariaDB [TestOpt]> 

These records have to be matched for joining whether the process starts with TestSmall (old case) or with TestBig (new case). So why does this take 23 seconds in the old case and 66 seconds in the new case?


general remarks

I don't want to show function hierarchies in this text, they are almost the same as in the old text. The action takes place in the function sub_select() and below, in the stage of the execution but it is above of any storage engine. I've used MyISAM as storage-engine in my tests but everything will happen identically in other storage-engines (but I didn't check this).


counting

In my old text I added some counters in the MyISAM-engine which lead to this output:
table = <TestSmall> counter table-scan:    301,037    index-scan: 0
table = <TestBig>   counter table-scan: 10,000,001    index-scan: 0

For the old case these numbers tell us that no index was used and every record was read once so the JOINing was done internally.

Let's look at the same numbers in the new case1):
table = <TestBig>   counter table-scan: 10,000,001    index-scan: 0
table = <TestSmall> counter table-scan: 17,159,109    index-scan: 0

The value for TestBig is identical (= no. of records in this table plus one) telling us that each record in TestBig is read once. In the new case the number for TestSmall looks confusing. Do not hesitate to take your calculator: 17,159,109 / 301,037 = 57. Does this mean that the server has read the table TestSmall 57times? At least this could explain the time-difference. But is this true?


the algorithm

Let's look at the algorithm in the old case: a record from TestSmall that matches the condition is put into a buffer, there were 60 records in the buffer after the table TestSmall is read. Now the server switches over to the table TestBig and read record after record from this table. If a record fulfills the WHERE-clause (aka Hersteller = '36367') the buffer is scanned for a match. This is realised as a double loop: an outer-loop (reading records from TestBig) and an inner loop (walking through the entries in the buffer). Counting the number of iterations in the inner loop and the outer loop leads to these numbers:
counterOuterLoop:    461,016
counterInnerLoop: 27,660,960

The number of iterations in the outer-loop is the number of matching records from TestBig, and 27,660,960 is the number of iterations in the inner-loop: this loop is called 461,016 times and iterates over the 60 entries in the buffer.


the new algorithm

The algorithm in the new case is identical to the old case, but there must be some differences that can explain the increase in the time needed. So let's start:
the table TestBig is read record after record and on each record the WHERE-condition is applied. When a match is found the needed information is put into the buffer, and the server continues reading the next record from TestBig, same as in the old case. But the buffer can hold 128K of data as this is the size that was allocated for it. After 8,205 matching records the buffer is full, it does not have enough space left for another entry. So the server starts the JOINing process: it reads the records from the table TestSmall, applies the WHERE-condition and if a match is found searches the entries in the buffer for a match in the column PZN.
When all records from TestSmall are read (and compared) the server marks the buffer as empty and continues reading the records from TestBig and putting matching records into the buffer. When this buffer is full again the server starts reading the records from TestSmall and searches through the buffer for matches. And so the server continues until all records are read from TestBig.

These are the numbers of iterations:
counterOuterLoop:      3,420
counterInnerLoop: 27,660,960

As you can see the number of iterations in the inner-loop are identical as the comparisons have to be made, independent of the order of the tables. But the number of iterations in the outer loop is much smaller.

Some lines above I showed you that there are 461,016 records in TestBig which fulfill the condition B.Hersteller = '36367'. Again please take your calculator and do this operation: 461,016 / 8,205 which results in a number of approx. 56.2. So the server fills the buffer 56times and has some records left for an additional iteration.

As you can see the server indeed reads the Table TestSmall 57times in a full table-scan. This explains the time needed for this statement.

And that ends my journey for today.


some experiments

Well, it doesn't, I'm still curious.

the buffer

As I've written above the server uses a buffer of size 128K. What about doubling the size of the buffer?

So let's do it. The buffer is allocated in this part of the code:
int JOIN_CACHE::alloc_buffer()
{
  ....
  buff_size= get_max_join_buffer_size(optimize_buff_size);
  ....
    if ((buff= (uchar*) my_malloc(buff_size, MYF(MY_THREAD_SPECIFIC)))) 
      break;
  ....
}

When we simply double the contents of the variable buff_size the buffer will have a size of 256K. Done, and here is the result:
MariaDB [TestOpt]> select SQL_NO_CACHE  count(A.PZN) from TestBig A IGNORE INDEX(PZN) straight_join TestSmall B  on (A.PZN = B.PZN) where B.Hersteller = '00020' and A.Hersteller = '36367';
+--------------+
| count(A.PZN) |
+--------------+
|           14 |
+--------------+
1 row in set (43.97 sec)

MariaDB [TestOpt]> 

Oh, 30% faster. And my counters tell me:
table = <TestBig>   counter table-scan: 10,000,001    index-scan: 0
table = <TestSmall> counter table-scan:  8,730,073    index-scan: 0

which means the table TestSmall is now read 29times.

So let's take back these modifications and continue with a buffer of size 128K.

the effect of the inner loop

The algorithm used in the code for the JOIN is a double loop. What if we eliminate the inner loop and see how much time is spent in this part of the code. Well, we will get a wrong number, but for this test I accepted this.
Here is the modified part of the code:
uchar *JOIN_CACHE_BNL::get_next_candidate_for_match()
{
//  if (!rem_records)
    return 0;
//  rem_records--;
//  return pos+base_prefix_length;
}

Compare this code with the original code (without the comment-characters) and you will see that the modified version simply returns 0. That says that there are no entries in the buffer and the inner loop will not be executed at all.
Here are the results:
MariaDB [TestOpt]> select SQL_NO_CACHE  count(A.PZN) from TestSmall A join TestBig B IGNORE INDEX (PZN) on (A.PZN = B.PZN) where A.Hersteller = '00020' and B.Hersteller = '36367';
+--------------+
| count(A.PZN) |
+--------------+
|            0 |
+--------------+
1 row in set (10.37 sec)

ariaDB [TestOpt]> select SQL_NO_CACHE  count(A.PZN) from TestBig A IGNORE INDEX(PZN) straight_join TestSmall B  on (A.PZN = B.PZN) where B.Hersteller = '00020' and A.Hersteller = '36367';
+--------------+
| count(A.PZN) |
+--------------+
|            0 |
+--------------+
1 row in set (53.16 sec)

In the old case 50% of the time is spent in the inner loop, in the new case 20% of the time is spent in the inner loop. In the new case a lot of time is spent reading the table TestSmall multiple times, so only a smaller fraction will be gained by omitting the inner loop.


suggestion

First of all I want to write that I did not find an error in the code, it works and it does it's job. But I think there is room for improvement, there should be a better (=faster) algorithm. Unfortunately I cannot give you one. You say that when JOINing tables one usually creates an index over the column(s) involved. Agreed, but please keep in mind that sometimes the optimizer needs a temporary table for the execution of your statement and then the effect described here can happen.


correctness

This text is a simplified presentation of what's going on in the software. As I'm still learning the inner-workings of this software errors on my side can happen. In case something is wrong with my description please use the mail-function of this site and send me a mail describing my error. I will look at it. Thanks.




some notes:
1) I had to modify my code a bit: the counters were initialized in ha_myisam::rnd_init(). This function was called multiple times whereas ha_myisam::rnd_end() was only called once so the counters were reset to 0 multiple times before the contents was written to the file. So I moved the initialization of the counters to the header-file. It's still not the correct place but for this test it was sufficient.