I’ve commented previously on the “new” cost-based Or-Expansion introduced in 12c to replace the “legacy” Concatenation transformation, and I’ve been re-running some of my concatenation scripts to see whether the most recent versions of the optimizer will use Or-expansion unhinted in places where I’ve previously had to use hints to force concatenation to appear.
The latest test has produced a surprising result – I’ve got an example where 19c and 21c will use concatenation when hinted with use_concat(), but will not obey the or_expand() hint on the grounds that there’s “No valid predicate for OR expansion”
It’s worth knowing this could happen if you’re upgrading from 11g to 19c (as many people seem to be doing at present) as you may find that you have some statements that used to use concatenation unhinted, but now need to be hinted to do so as they can’t switch to or-expansion and won’t use concatenation unless hinted to do so.
tl;dr (the rest of the note is just a demonstration.) When you upgrade from 11g to 19c (or later) you may find that some queries perform badly because they stop using the legacy “concatenation” operator but can’t be transformed by the new “cost-based Or Expand” operator, and need to be hinted with a use_concat() hint.
Here’s a statement I can use to demonstrate the effect – I’ll post the code to create the tables at the end of the note:
select /*+ gather_plan_statistics */
n1, n2, small_vc
from
t1
where
(n1 = 1 and n2 = 10000)
or (n1 = 10000 and n2 = 1)
;
I’ve rigged the data so that there are 9,999 distinct values of n1 each with one row, and 10,001 rows with the value 10,000; and I’ve done the same with n2 – 9,999 distinct values with one row each and 10,001 rows with the value 10,000.
I’ve gathered stats that include histograms on n1 and n2 (separately) and I’ve created indexes on n1 and n2 (separately). As a result the ideal path for this query is to use the index on n1 to find rows for the first of the two compound predicates and use the index on n2 to find rows for the second of the predicates, which should be possible if the optimizer first transforms the query using OR-expansion.
You’ll notice I’ve included the hint to capture rowsource execution statistics, so I’ll be executing this query with various hints and reporting the actual execution plans and workload. Using 19.11.0.0 and 21.3.0.0 with no special parameter settings the execution plan that appeared used B-tree/bitmap conversion:
| Id | Operation | Name | Starts | E-Rows | Cost (%CPU)| A-Rows | A-Time | Buffers |
--------------------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 45 (100)| 2 |00:00:00.01 | 50 |
| 1 | TABLE ACCESS BY INDEX ROWID BATCHED| T1 | 1 | 1 | 45 (3)| 2 |00:00:00.01 | 50 |
| 2 | BITMAP CONVERSION TO ROWIDS | | 1 | | | 2 |00:00:00.01 | 48 |
| 3 | BITMAP OR | | 1 | | | 1 |00:00:00.01 | 48 |
| 4 | BITMAP AND | | 1 | | | 1 |00:00:00.01 | 24 |
| 5 | BITMAP CONVERSION FROM ROWIDS | | 1 | | | 1 |00:00:00.01 | 2 |
|* 6 | INDEX RANGE SCAN | T1_N1 | 1 | | 1 (0)| 1 |00:00:00.01 | 2 |
| 7 | BITMAP CONVERSION FROM ROWIDS | | 1 | | | 1 |00:00:00.01 | 22 |
|* 8 | INDEX RANGE SCAN | T1_N2 | 1 | | 21 (0)| 10001 |00:00:00.01 | 22 |
| 9 | BITMAP AND | | 1 | | | 1 |00:00:00.01 | 24 |
| 10 | BITMAP CONVERSION FROM ROWIDS | | 1 | | | 1 |00:00:00.01 | 2 |
|* 11 | INDEX RANGE SCAN | T1_N2 | 1 | | 1 (0)| 1 |00:00:00.01 | 2 |
| 12 | BITMAP CONVERSION FROM ROWIDS | | 1 | | | 1 |00:00:00.01 | 22 |
|* 13 | INDEX RANGE SCAN | T1_N1 | 1 | | 21 (0)| 10001 |00:00:00.01 | 22 |
--------------------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
6 - access("N1"=1)
8 - access("N2"=10000)
11 - access("N2"=1)
13 - access("N1"=10000)
This is a fairly clever plan but not what I wanted to test so I set the hidden parameter ‘_b_tree_bitmap_plans’ to false for all subsequent tests. With this block in place the plan changed to a full tablescan:
-------------------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | Cost (%CPU)| A-Rows | A-Time | Buffers |
-------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 99 (100)| 2 |00:00:00.01 | 349 |
|* 1 | TABLE ACCESS FULL| T1 | 1 | 1 | 99 (2)| 2 |00:00:00.01 | 349 |
-------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter((("N1"=1 AND "N2"=10000) OR ("N1"=10000 AND "N2"=1)))
Definitely not what I wanted – so I added a hint telling the optimizer I wanted to see OR-expansion. The optimizer produced the same full tablescan! Since I had included the format option ‘hint_report’ in my call to dbms_xplan.display_cursor() I can show you the extra lines of output that explained why the optimizer “ignored” my hint:
Hint Report (identified by operation id / Query Block Name / Object Alias):
Total hints for statement: 1 (U - Unused (1))
---------------------------------------------------------------------------
1 - SEL$1
U - or_expand(@sel$1 (1) (2)) / No valid predicate for OR expansion
As you can see the hint was not “N – unresolved” or “E – Syntax error”. It was recognised, syntactically correct, notionally applicable but unused because the optmizer couldn’t see a way to use it (even though we can see an obvious way to use it).
Idle curiosity then prompted me to try the use_concat() hint, in the form: “use_concat(@sel$1 1)” – here’s the resulting execution plan:
---------------------------------------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | Cost (%CPU)| A-Rows | A-Time | Buffers |
---------------------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 4 (100)| 2 |00:00:00.01 | 7 |
| 1 | CONCATENATION | | 1 | | | 2 |00:00:00.01 | 7 |
|* 2 | TABLE ACCESS BY INDEX ROWID BATCHED| T1 | 1 | 1 | 2 (0)| 1 |00:00:00.01 | 4 |
|* 3 | INDEX RANGE SCAN | T1_N2 | 1 | 1 | 1 (0)| 1 |00:00:00.01 | 3 |
|* 4 | TABLE ACCESS BY INDEX ROWID BATCHED| T1 | 1 | 1 | 2 (0)| 1 |00:00:00.01 | 3 |
|* 5 | INDEX RANGE SCAN | T1_N1 | 1 | 1 | 1 (0)| 1 |00:00:00.01 | 2 |
---------------------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - filter("N1"=10000)
3 - access("N2"=1)
4 - filter(("N2"=10000 AND (LNNVL("N2"=1) OR LNNVL("N1"=10000))))
5 - access("N1"=1)
Exactly the plan I wanted to see from or_expand(), although the two subqueries are in the reverse order to the order I would expect from or_expand(). So the new cost-based or-expansion says there’s no valid predicate available for expansion, but the old, deprecated, heuristic, concatenation transformation manages to find a disjunct (OR) that can be expanded.
Of course the next thing to do is look at the predicted cost and actual work (mostly buffer gets) that Oracle reported for each plan:
- bitmap conversion: (cost 45, buffers 50)
- full tablescan: (cost 99, buffers 349)
- concatenation: (cost 4, buffers 7)
The predicted costs are actually fairly consistent with buffer gets (which, if I flushed the cache, would also be mostly disk reads). I had been fairly impressed that the optimizer picked bitmap conversion, but it would have been so much better if the optimizer could see that this (slightly complex) set of predicates included an opportunity for or-expansion.
Footnote 1
This query shows an example of disjunctive normal form (DNF), i.e the where clause is a disjunct (OR) of conjuncts (ANDs). I understand that optimizers (in general) quite like this form, but there is another “nice” form which is CNF (conjunctive normal form) i.e. where the where clause is a conjuct (AND) of disjuncts (ORs). So, for entertainment, I rewrote the where clause in conjunctive normal form. You have to be a little careful when you play the “normal form” game, it’s quite easy to get it wrong, so here are the steps I took (using A, B, C, D instead of my 4 atomic predicates):
(A and B) or (C and D) ==
(A or (C and D)) and (B or (C and D)) == -- distributing the (A and B)
(A or C) and (A or D) and (B or C) and (B or D) -- distributing the two occurrences of (C and D)
Here’s the restulting query and unhinted execution plan after substituting “n = 1” etc. back into the symbolic presentation (and it probably gives you some idea why I played safe by starting with A, B, C, D):
select /*+ gather_plan_statistics */
n1, n2, small_vc
from
t1
where
(n1 = 1 or n2 = 1)
and (n1 = 1 or n1 = 10000)
and (n2 = 10000 or n2 = 1)
and (n2 = 10000 or n1 = 10000)
;
--------------------------------------------------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | Cost (%CPU)| A-Rows | A-Time | Buffers |
--------------------------------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 4 (100)| 2 |00:00:00.01 | 7 |
| 1 | VIEW | VW_ORE_BA8ECEFB | 1 | 2 | 4 (0)| 2 |00:00:00.01 | 7 |
| 2 | UNION-ALL | | 1 | | | 2 |00:00:00.01 | 7 |
|* 3 | TABLE ACCESS BY INDEX ROWID BATCHED| T1 | 1 | 1 | 2 (0)| 1 |00:00:00.01 | 4 |
|* 4 | INDEX RANGE SCAN | T1_N1 | 1 | 1 | 1 (0)| 1 |00:00:00.01 | 3 |
|* 5 | TABLE ACCESS BY INDEX ROWID BATCHED| T1 | 1 | 1 | 2 (0)| 1 |00:00:00.01 | 3 |
|* 6 | INDEX RANGE SCAN | T1_N2 | 1 | 1 | 1 (0)| 1 |00:00:00.01 | 2 |
--------------------------------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
3 - filter("N2"=10000)
4 - access("N1"=1)
5 - filter(("N1"=10000 AND LNNVL("N1"=1)))
6 - access("N2"=1)
It’s the OR-expansion I wanted to see.
If I can do an algorithmic rewrite that produces the desired plan the optimizer can be coded to do the rewrite – so I think you can expect to see this limitation removed at some future point. This plan, however, did still depend on my disabling B-tree/bitmap conversion; when I enabled B-tree/bimap conversion the optimizer used it to produce the following plan:
--------------------------------------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | Cost (%CPU)| A-Rows | A-Time | Buffers |
--------------------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 2 (100)| 2 |00:00:00.01 | 6 |
|* 1 | TABLE ACCESS BY INDEX ROWID BATCHED| T1 | 1 | 1 | 2 (0)| 2 |00:00:00.01 | 6 |
| 2 | BITMAP CONVERSION TO ROWIDS | | 1 | | | 2 |00:00:00.01 | 4 |
| 3 | BITMAP OR | | 1 | | | 1 |00:00:00.01 | 4 |
| 4 | BITMAP CONVERSION FROM ROWIDS | | 1 | | | 1 |00:00:00.01 | 2 |
|* 5 | INDEX RANGE SCAN | T1_N1 | 1 | | 1 (0)| 1 |00:00:00.01 | 2 |
| 6 | BITMAP CONVERSION FROM ROWIDS | | 1 | | | 1 |00:00:00.01 | 2 |
|* 7 | INDEX RANGE SCAN | T1_N2 | 1 | | 1 (0)| 1 |00:00:00.01 | 2 |
--------------------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter((INTERNAL_FUNCTION("N1") AND INTERNAL_FUNCTION("N2") AND ("N2"=10000 OR "N1"=10000)))
5 - access("N1"=1)
7 - access("N2"=1)
The thing to note in this case, though, is that the B-tree/bitmap conversion is logically the correct thing to choose when you compare the estimated cost and actual workload:
- or-expansion: (cost 4, buffers 7)
- bitmap conversion: (cost 2, buffers 6)
Footnote 2
Mohamed Houri wrote an article on Or-expansion a year ago explaining the possible settings for the hidden parameter “_optimizer_cbqt_or_expansion”, which can off, on, linear, greedy or two_pass. I tried all the options to see if that would make any difference (apart from the obvious impact of “off”); but it didn’t.
Source code
If you want to do further experiments, here’s the script I used to generate the data:
rem
rem Script: concat_3b.sql
rem Author: Jonathan Lewis
rem Dated: Sep 2008 / Jan 2003
rem Purpose:
rem
rem Last tested
rem 19.11.0.0
rem 21.3.0.0
rem
create table t1
as
with generator as (
select
rownum id
from dual
connect by level <= 10000
)
select
rownum n1,
10000 n2,
lpad(rownum,10,'0') small_vc,
rpad('x',100) padding
from
generator v1
;
insert /*+ append */ into t1
select
n2, n1, small_vc, padding
from
t1
;
commit;
create index t1_n1 on t1(n1);
create index t1_n2 on t1(n2);
begin
dbms_stats.gather_table_stats(
ownname => user,
tabname =>'T1',
method_opt => 'for columns size 100 n1, n2'
);
end;
/