Q13 is one of the longest running of the 22 queries. The TPC-H metric is a geometric mean of two scores, power and throughput, where the throughput score is the elapsed time of the multiuser part of the test divided by the number of queries executed. In this part of the score, Q13 can be up to 1/5 of the total. The power score on the other hand is a geometric mean of the run times of all the queries, scaled into queries per hour. There all queries have equal importance. A bad Q13 will sink a whole result.

Q13 counts the orders of each customer and then shows, for each distinct count of orders, how many customers have this number of orders. 1/3 of the customers have no orders; hence this is an outer join between customers and orders, as follows:

  SELECT              c_count,
          COUNT(*) AS custdist
    FROM  (    SELECT                      c_custkey,
                      COUNT(o_orderkey) AS c_count
                FROM  ( SELECT * 
                          FROM customer
                               LEFT OUTER JOIN orders 
                                    c_custkey = o_custkey 
                                    o_comment NOT LIKE '%special%requests%'
                      ) c_customer
             GROUP BY  c_custkey
          ) c_orders
GROUP BY  c_count
ORDER BY  custdist DESC,
          c_count DESC

The only parameter of the query is the pattern in the NOT LIKE condition. The NOT LIKE is very unselective, so almost all orders will be considered.

The Virtuoso run time for Q13 is 6.7s, which we can consider a good result. Running 5 of these at the same time has the fastest execution finishing in 23.7s and the slowest in 35.3s. Doing 5x the work takes 5.2x the time. This is not bad, considering that the query has a high transient memory consumption. A second execution of 5 concurrent Q13s has the fastest finishing in 22.s and the slowest in 29.8s. The difference comes from already having the needed memory blocks cached, so there are no calls to the OS for mapping more memory.

To measure the peak memory consumption, which is a factor with this query, there is the mp_max_large_in_use counter. To reset:

__dbf_set ('mp_max_large_in_use', 0);

To read:

SELECT sys_stat ('mp_max_large_in_use');

For the 5 concurrent executions of Q13, the counter goes to 10GB. This is easily accommodated at 100 GB; but at ten times the scale, this will be a significant quantity, even in a scale out setting. The memory allocation time is recorded in the counter mp_mmap_clocks, read with sys_stat. This is a count of cycles spent waiting for mmap or munmap and allows tracking if the process is being slowed down by transient memory allocation.

Let us consider how this works. The plan is as follows:

{ hash filler
CUSTOMER   1.5e+07 rows(t3.C_CUSTKEY)

-- Make a hash table of the 150M customers. The stage 2 operator below means that the customers are partitioned in a number of distinct partitions based on the c_custkey, which is the key in the hash table. This means that a number of disjoint hash tables are built, as many as there are concurrent threads. This corresponds to the ThreadsPerQuery ini file setting of the enable_qp setting with __dbf_set and sys_stat.

Stage 2
Sort hf 34 (q_t3.C_CUSTKEY)
{ fork
{ fork
{ fork
END Node
outer {

-- Here we start a RIGHT OUTER JOIN block. The below operator scans the orders table and picks out the orders which do not contain the mentioned LIKE pattern.

ORDERS   1.5e+08 rows(t4.O_CUSTKEY, t4.O_ORDERKEY)
hash partition+bloom by 80 ()


-- Below is a partitioning operator, also known as an exchange operator, which will divide the stream of o_custkeys from the previous scan into different partitions, each served by a different thread.

Stage 2

-- Below is a lookup in the customer hash table. The lookup takes place in the partition determined by the o_custkey being looked up.

Hash source 34  not partitionable         1 rows(q_t4.O_CUSTKEY) -> ()
 right oj, key out ssls: (t3.C_CUSTKEY)
After code:
      0: t3.C_CUSTKEY :=  := artm t4.O_CUSTKEY
      4: BReturn 0

-- The below is a RIGHT OUTER JOIN end operator; see below for further description

 end of outer}
 out: (t4.O_ORDERKEY, t4.O_CUSTKEY)
 shadow: (t4.O_ORDERKEY, t4.O_CUSTKEY)
      0: isnotnull := Call isnotnull (t4.O_ORDERKEY)
      5: BReturn 0

-- The below sort is the innermost GROUP BY. The ISNOTNULL above makes a 0 or a 1, depending on whether there was a found o_custkey for the c_custkey of the customer.

Sort (t3.C_CUSTKEY) -> (isnotnull)

-- The below operators start after the above have executed to completion on every partition. We read the first aggregation, containing for each customer the COUNT of orders.

group by read node  
(t3.C_CUSTKEY, aggregate)in each partition slice
After code:
      0: c_custkey :=  := artm t3.C_CUSTKEY
      4: c_count :=  := artm aggregate
      8: BReturn 0
Subquery Select(c_custkey, c_count)

-- Below is the second GROUP BY; for each COUNT, we count how many customers have this many orders.

Sort (c_count) -> (inc)
group by read node  
(c_count, custdist)

-- Below is the final ORDER BY.

Sort (custdist, c_count)
Key from temp (c_count, custdist)
Select (c_count, custdist)

The CPU profile starts as follows:

971537   31.8329           setp_chash_run
494300   16.1960           hash_source_chash_input_1i_n
262218    8.5917           clrg_partition_dc
162773    5.3333           strstr_sse42
68049     2.2297           memcpy_16
65883     2.1587           cha_insert_1i_n
57515     1.8845           hs_send_output
56093     1.8379           cmp_like_const
53752     1.7612           gb_aggregate
51274     1.6800           cha_rehash_ents

The GROUP BY is on top, with 31%. This is the first GROUP BY, which has one group per customer, for a total of 150M groups. Below the GROUP BY is the hash lookup of the hash join from orders to customer. The third item is partitioning of a data column (dc, or vectored query variable). The partitioning refers to the operator labeled stage 2 above. From one column of values, it makes several. In the 4th place, we have the NOT LIKE predicate on o_comment. This is a substring search implemented using SSE 4.2 instructions. Finally, in the last place, there is a function for resizing a hash table; in the present case, the hash table for the innermost GROUP BY.

At this point, we have to explain the RIGHT OUTER JOIN: Generally when making a hash join, the larger table is on the probe side and the smaller on the build side. This means that the rows on the build side get put in a hash table and then for each row on the probe side there is a lookup to see if there is a match in the hash table.

However, here the bigger table is on the right side of LEFT OUTER JOIN. Normally, one would have to make the hash table from the orders table and then probe it with customer, so that one would find no match for the customers with no orders and several matches for customers with many orders. However, this would be much slower. So there is a trick for reversing the process: You still build the hash from the smaller set in the JOIN, but now for each key that does get probed, you set a bit in a bit mask, in addition to sending the match as output. After all outputs have been generated, you look in the hash table for the entries where the bit is not set. These correspond to the customers with no orders. For these, you send the c_custkey with a null o_orderkey to the next operator in the pipeline, which is the GROUP BY on c_custkey with the count of non-null o_orderkeys.

One might at first think that such a backwards way of doing an outer join is good for nothing but this benchmark and should be considered a benchmark special. This is not so, though, as there are accepted implementations that do this very thing.

Furthermore, getting a competitive score in any other way is impossible, as we shall see below.

We further note that the the grouping key in the innermost GROUP BY is the same as the hash key in the last hash join, i.e., o_custkey. This means that the GROUP BY and the hash join could be combined in a single operator called GROUPJOIN. If this were done, the hash would be built from customer with extra space left for the counters. This would in fact remove the hash join from the profile as well as the rehash of the group by hash table, for a gain of about 20%. The outer join behavior is not a problem here since untouched buckets, e.g., customers without orders, would be inited with a COUNT of 0. For an inner join behavior, one would simply leave out the zero counts when reading the GROUP BY. At the end of the series, we will see what the DBT3 score will be. We remember that there is a 1.5s savings to be had here for the throughput score if the score is not high enough otherwise. The effect on the power score will be less because that only cares about relative speedup, not absolute time.

Next, we disable the RIGHT OUTER JOIN optimization and force the JOIN to build a hash on orders and to probe it with customer. The execution time is 25s. Most of the time goes into building the hash table of orders. The memory consumption also goes up to around 8G. Then we try the JOIN by index with a scan of customer, and for each an index lookup of orders based on an index on o_custkey. Here we note that there is a condition on a dependent part of the primary key, namely o_comment, which requires joining to the main row from the o_ck index. There is a gain however because the GROUP BY becomes ordered; i.e., there is no need to keep groups around for customers that have already been seen since we know they will not come again, the outer scan being in order of c_custkey. For this reason, the memory consumption for the GROUP BY goes away. However, the index-based plan is extremely sensitive to vector size: The execution takes 29.4s if vector size is allowed to grow to 1MB, but 413s if it stays at the default of 10KB. The difference is in the 1MB vector hitting 1/150 (1 million lookups for a 150 million row table), whereas the 10KB vector hits 1/15000. Thus, benefits from vectoring lookups are largely lost, since there are hardly ever hits in the same segment; in this case, within 2000 rows. But this is not the main problem: The condition on the main row is a LIKE on a long column. Thus, the whole column for the segment in question must be accessed for read, meaning 2000 or so o_comments, of which one will be checked. If instead of a condition on o_comment, we have one on o_totalprice > 0, we get 93s with 10KB vector size and 15s with dynamic up to 1MB.

If we now remove the condition on dependent columns of orders, the index plan becomes faster, since the whole condition is resolved within the o_custkey index -- 2.5s with 10KB vector size, 2.6s with dynamic vector size up to 1MB. The point here is that the access from customer to orders on the o_custkey index is ordered, like a merge join.

Q13 Conclusions

Q13 is a combo of many choke points in the TPC-H Analyzed paper. The most important is special JOIN types, i.e., RIGHT OUTER JOIN and GROUPJOIN. Then there is string operation performance for the substring matching with LIKE. This needs to be implemented with the SSE 4.2 string instructions; otherwise there is a hit of about 0.5s on query speed.

The TPC-H Analyzed paper was written against the background of analytical DB tradition where the dominant JOIN type is hash, except when there is a merge between two sets that are ordered or at least clustered on the same key. Clustered here means physical order but without the need to be strictly in key order.

Here I have added some index based variants to show that hash join indeed wins and to point out the sensitivity of random access to vector size. As column stores go, Virtuoso is especially good at random access. This must be so since it was optimized to do RDF well, which entails a lot of lookup. Also note how a big string column goes with great ease in a sequential scan, but kills in a non-local random access pattern.

In Hoc Signo Vinces Series