Skip to content

MDEV-32286 Reuse remembered clustered leaves in secondary-index scans - #5625

Open
iMineLink wants to merge 1 commit into
11.8from
11.8-MDEV-32286
Open

MDEV-32286 Reuse remembered clustered leaves in secondary-index scans#5625
iMineLink wants to merge 1 commit into
11.8from
11.8-MDEV-32286

Conversation

@iMineLink

@iMineLink iMineLink commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Row_sel_get_clust_rec_for_mysql::operator() descends the clustered B-tree
from the root for every row whose clustered-index record a secondary-index
scan must read, although consecutive rows land on the same clustered leaf
page wherever the secondary order tracks the clustered one. A non-covering
scan needs one for every row, a locking read needs one whatever the
secondary index holds, because an exclusive select lock type makes
ha_innobase::build_template() build its template against the clustered
index, and a covering scan needs one for every row of a secondary leaf
whose PAGE_MAX_TRX_ID its read view cannot see. ANALYZE FORMAT=JSON
charges each descent its full height, and those descents are nearly the
whole cost: the secondary index is charged its own descent and one page
for each further leaf, and nothing per row, because the position that its
cursor holds between two rows is restored optimistically, which latches
the leaf again without counting an access. So pages_accessed is the row
count times the height of the clustered index, plus a handful: 1000 rows
over a 2-level clustered index cost 2006 and 750 rows over a 3-level one
cost 2291, where a full table scan of the same data costs 23 and 110.

Let a handle remember the clustered leaves that the lookups of one
statement reached, and let the next lookup try them before it descends
again. The reasoning behind each value and each rejection is in the
comments beside it.

row0mysql.h defines clust_leaf_hint_slot, which names one leaf: its page
number, copies of its first and last user record truncated to the key
fields, which bound the key range that the leaf held when it was
remembered, the rec_get_offsets() of both, and the
dict_index_t::n_core_fields that the copies were made under. A slot of a
leaf that had no right sibling names no last key, because every key above
the last record of the rightmost leaf still belongs to it.
CLUST_LEAF_HINT_SLOTS (4) slots hang off the new
row_prebuilt_t::clust_leaf_hint, beside clust_leaf_hint_mru, the most
recently used order held as slot numbers, and clust_leaf_hint_n and
clust_leaf_hint_miss, the used-slot count and the miss counter.

row0sel.cc holds the policy. row_sel_clust_leaf_hint_covers() compares a
key against the remembered ranges, so a lookup that no slot can answer
costs no buffer pool access and no pages_accessed.
row_sel_clust_leaf_hint_search() probes the first slot that covers the key
and moves it to the front of the order.
row_sel_clust_leaf_hint_remember() records the leaf that a descent landed
on, and refreshes the slot of a leaf that is remembered already rather
than spend a second one on the same page. Two descents fill no slot: the
first CLUST_LEAF_HINT_MIN_LOOKUPS (4) lookups of a statement, and a leaf
that is the root. row_sel_clust_leaf_hint_armed() stands a scan down once
the slots stop paying for themselves: a miss adds
CLUST_LEAF_HINT_MISS_WEIGHT (2) to the miss counter and a hit takes one
away, so a scan gives the slots up where it answers too little of its
lookups to pay for them, CLUST_LEAF_HINT_MAX_MISSES (8) misses with no hit
between them still reach the threshold, and one lookup in
CLUST_LEAF_HINT_RETRY (1024) starts the count again, so a scan whose order
becomes correlated only later recovers. Both halves of the cost stop
there, the test of the slots and the copies that refresh them.
Row_sel_get_clust_rec_for_mysql::operator() calls all of this in place of
its btr_pcur_open_with_no_init(), and only where the adaptive hash index
is disabled, whose guess solves the same problem better: it lands on the
record with no page-local search and no page access to charge. That index
is off by default, so the hints are active in a default configuration.

btr0cur.h and btr0cur.cc add btr_cur_t::try_leaf_hint(), a PAGE_CUR_LE,
BTR_SEARCH_LEAF search on one named leaf. It acquires the page with
buf_page_try_get(): a hint is never derived from a latched parent page, so
by the time it is tried it can precede the caller's already-latched
secondary-index leaf in the latching order, where a blocking wait can
deadlock. It then rejects the page unless the checks that it makes on the
latched frame put the match on it. Those checks are the sole authority on
the result, so a stale range costs a wasted probe or a needless descent,
never a wrong result, and the ranges need no invalidation protocol.

ha_innodb.cc: ha_innobase::reset() zeroes the used-slot count and the miss
counter per statement, matching autoinc_last_value. row0mysql.cc:
row_prebuilt_free() frees the key buffers that the slots own.

innodb.clust_leaf_hint measures pages_accessed over key orders that differ
in how closely the secondary order tracks the clustered one, and eight
further tables check query results over the record formats and key shapes
that a clustered-index lookup has to read, down to the metadata
pseudo-record of instant ALTER TABLE, to leaves that split and merge while
a locking read walks them, and to a record that a remembered leaf supplies
for a scan that must then rebuild an older version of it. Two of the
tables scan a covering index, which reads a clustered record under an
exclusive select lock type and under a PAGE_MAX_TRX_ID that the read view
cannot see. clust_leaf_hint_off_debug runs the same body with the hints
turned off, through a debug switch that returns before a lookup tests or
refreshes the slots, so a diff of the two .result files is what the hints
save: 2006 to 1031 (2-level clustered index), 2291 to 1011 (3-level), 4006
to 2015 (two interleaved key ranges), 12016 to 9078 (locality in the
second half alone) and 20020 to 10045 for a covering scan that FOR UPDATE
makes non-covering, where the same scan without FOR UPDATE costs 20 in
both files. Two orders with too little locality to pay for the slots give
them up early and end within a hundred accesses of the unhinted count:
20020 to 19966 (decorrelated) and 20020 to 19999 (shuffled).

innodb.clust_leaf_hint_instant_alter covers the one rejection that no
count reaches, of a slot whose keys were copied under another
dict_index_t::n_core_fields than the index reports.
dict_index_t::clear_instant_alter() is the only writer of that value that
a shared metadata lock allows, and it needs the clustered index to lose
the last user record of its root page, while no leaf that is the root
fills a slot, so the tree has to shrink between the two, which purge does
there. The reader therefore reads uncommitted rows at READ UNCOMMITTED and
waits in a stored function while a rollback and purge take them away, and
one row that arrives above the position it stopped at is the lookup that
tests the slots. The rejection leaves nothing that a query can read, so
that branch writes the two counts to the error log under a debug switch
and the case reads them back with search_pattern_in_file.inc. They are
printed and not named in the pattern, so that a run which reaches the
branch with other counts, or in the direction where the clear lowers them,
is a difference to look at and not a pass.

main.rowid_filter_innodb: 90 to 88, and its ahi combination unchanged.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@Thirunarayanan Thirunarayanan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

row_sel_clust_leaf_hint_covers() discards a slot whose n_core_fields no
longer matches the index, because a delete that empties a single-page table
invokes dict_index_t::clear_instant_alter(). Are we have any test case for it?

Comment thread storage/innobase/include/btr0cur.h Outdated
Comment thread storage/innobase/include/row0mysql.h Outdated
Comment thread storage/innobase/row/row0sel.cc Outdated
Comment thread storage/innobase/include/row0mysql.h Outdated
Comment thread storage/innobase/row/row0sel.cc Outdated
and rec_copy_prefix_to_buf() can move a copy when it grows its buffer.
prebuilt->heap is passed for a growth that the sizing above rules out,
so that an array which did grow would still outlive the statement. */
hint.first= rec_copy_prefix_to_buf(first, index, n_fields, &hint.first_buf,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already going through per-field offsets in rec_copy_prefix_to_buf(). Again we do the same in rec_get_offsets()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but it seems not easy to extract the offsets computed in rec_copy_prefix_to_buf() covering all handled cases. This may belong to a future optimization which requires some refactoring, if row_sel_clust_leaf_hint_remember() becomes hot in some workloads.

Comment thread storage/innobase/row/row0sel.cc Outdated
Comment thread storage/innobase/include/row0mysql.h Outdated
authority. It therefore needs no invalidation protocol and no
modify_clock guard. */
struct clust_leaf_hint_t {
const rec_t* first; /*!< copy of the leaf's first user

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use parallel array? first[4], last[4] etc... Because covers[] won't touch first_buf, first_buf_size, last_buf, last_buf_size at all. Just an idea though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, especially after the suggested resize of the clust_leaf_hint_t struct (to be renamed to remove the _t), that the fields accessed in row_sel_clust_leaf_hint_covers() are already packed enough, and the current form makes row_sel_clust_leaf_hint_move() easier, since the struct holds all one slot's fields. Again, this can be refactored however if it ever shows hot later. I still have to address the "MRU variable" comment though, but I don't think it'll change my answer here.

@iMineLink
iMineLink force-pushed the 11.8-MDEV-32286 branch 2 times, most recently from 0fad331 to 386d928 Compare September 10, 2026 12:36
@iMineLink

Copy link
Copy Markdown
Contributor Author

I addressed all previous review comments (thanks for the detail of those).
To prevent regressions in the peak performance case, that is resident trees where descent is cheap, misses are now weighted twice as much as hits and a rolling counter is maintained: hints are disarmed once the counter reaches 16.
In addition, hint slot filling is gated by a counter (similar to fetch-cache enabling mechanism) to differentiate "~point lookups" from "scans", enabling the hints only in the latter case.
Performance testing is required to validate this mechanism before merging.
Reduction in pages_accessed count in favorable cases is already proved by the difference between test results (hints vs no hints).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The debug wrapper can leak its global DBUG setting when the sourced test skips.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds statement-scoped clustered-leaf hints to reduce repeated B-tree descents during secondary-index scans.

Changes:

  • Implements leaf-hint caching, validation, retry, and eviction.
  • Integrates lifecycle management and adaptive-hash-index handling.
  • Adds extensive correctness and performance regression coverage.
File summaries
File Description
storage/innobase/row/row0sel.cc Implements hint policy and lookup integration.
storage/innobase/row/row0mysql.cc Frees retained key buffers.
storage/innobase/include/row0mysql.h Defines hint storage and counters.
storage/innobase/include/btr0cur.h Declares direct leaf probing.
storage/innobase/handler/ha_innodb.cc Resets hints per statement.
storage/innobase/btr/btr0cur.cc Implements validated nonblocking leaf probes.
mysql-test/suite/innodb/t/clust_leaf_hint.test Tests performance and correctness scenarios.
mysql-test/suite/innodb/t/clust_leaf_hint_off_debug.test Provides an unhinted debug comparison.
mysql-test/suite/innodb/t/clust_leaf_hint_instant_alter.test Tests stale instant-ALTER metadata handling.
mysql-test/suite/innodb/r/clust_leaf_hint.result Records hinted results.
mysql-test/suite/innodb/r/clust_leaf_hint_off_debug.result Records unhinted results.
mysql-test/suite/innodb/r/clust_leaf_hint_instant_alter.result Records instant-ALTER results.
mysql-test/main/rowid_filter_innodb.result Updates page-access expectations.
mysql-test/main/rowid_filter_innodb,ahi.rdiff Updates AHI result differences.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread mysql-test/suite/innodb/t/clust_leaf_hint_off_debug.test Outdated
Row_sel_get_clust_rec_for_mysql::operator() descends the clustered B-tree
from the root for every row whose clustered-index record a secondary-index
scan must read, although consecutive rows land on the same clustered leaf
page wherever the secondary order tracks the clustered one. A non-covering
scan needs one for every row, a locking read needs one whatever the
secondary index holds, because an exclusive select lock type makes
ha_innobase::build_template() build its template against the clustered
index, and a covering scan needs one for every row of a secondary leaf
whose PAGE_MAX_TRX_ID its read view cannot see. ANALYZE FORMAT=JSON
charges each descent its full height, and those descents are nearly the
whole cost: the secondary index is charged its own descent and one page
for each further leaf, and nothing per row, because the position that its
cursor holds between two rows is restored optimistically, which latches
the leaf again without counting an access. So pages_accessed is the row
count times the height of the clustered index, plus a handful: 1000 rows
over a 2-level clustered index cost 2006 and 750 rows over a 3-level one
cost 2291, where a full table scan of the same data costs 23 and 110.

Let a handle remember the clustered leaves that the lookups of one
statement reached, and let the next lookup try them before it descends
again. The reasoning behind each value and each rejection is in the
comments beside it.

row0mysql.h defines clust_leaf_hint_slot, which names one leaf: its page
number, copies of its first and last user record truncated to the key
fields, which bound the key range that the leaf held when it was
remembered, the rec_get_offsets() of both, and the
dict_index_t::n_core_fields that the copies were made under. A slot of a
leaf that had no right sibling names no last key, because every key above
the last record of the rightmost leaf still belongs to it.
CLUST_LEAF_HINT_SLOTS (4) slots hang off the new
row_prebuilt_t::clust_leaf_hint, beside clust_leaf_hint_mru, the most
recently used order held as slot numbers, and clust_leaf_hint_n and
clust_leaf_hint_miss, the used-slot count and the miss counter.

row0sel.cc holds the policy. row_sel_clust_leaf_hint_covers() compares a
key against the remembered ranges, so a lookup that no slot can answer
costs no buffer pool access and no pages_accessed.
row_sel_clust_leaf_hint_search() probes the first slot that covers the key
and moves it to the front of the order.
row_sel_clust_leaf_hint_remember() records the leaf that a descent landed
on, and refreshes the slot of a leaf that is remembered already rather
than spend a second one on the same page. Two descents fill no slot: the
first CLUST_LEAF_HINT_MIN_LOOKUPS (4) lookups of a statement, and a leaf
that is the root. row_sel_clust_leaf_hint_armed() stands a scan down once
the slots stop paying for themselves: a miss adds
CLUST_LEAF_HINT_MISS_WEIGHT (2) to the miss counter and a hit takes one
away, so a scan gives the slots up where it answers too little of its
lookups to pay for them, CLUST_LEAF_HINT_MAX_MISSES (8) misses with no hit
between them still reach the threshold, and one lookup in
CLUST_LEAF_HINT_RETRY (1024) starts the count again, so a scan whose order
becomes correlated only later recovers. Both halves of the cost stop
there, the test of the slots and the copies that refresh them.
Row_sel_get_clust_rec_for_mysql::operator() calls all of this in place of
its btr_pcur_open_with_no_init(), and only where the adaptive hash index
is disabled, whose guess solves the same problem better: it lands on the
record with no page-local search and no page access to charge. That index
is off by default, so the hints are active in a default configuration.

btr0cur.h and btr0cur.cc add btr_cur_t::try_leaf_hint(), a PAGE_CUR_LE,
BTR_SEARCH_LEAF search on one named leaf. It acquires the page with
buf_page_try_get(): a hint is never derived from a latched parent page, so
by the time it is tried it can precede the caller's already-latched
secondary-index leaf in the latching order, where a blocking wait can
deadlock. It then rejects the page unless the checks that it makes on the
latched frame put the match on it. Those checks are the sole authority on
the result, so a stale range costs a wasted probe or a needless descent,
never a wrong result, and the ranges need no invalidation protocol.

ha_innodb.cc: ha_innobase::reset() zeroes the used-slot count and the miss
counter per statement, matching autoinc_last_value. row0mysql.cc:
row_prebuilt_free() frees the key buffers that the slots own.

innodb.clust_leaf_hint measures pages_accessed over key orders that differ
in how closely the secondary order tracks the clustered one, and eight
further tables check query results over the record formats and key shapes
that a clustered-index lookup has to read, down to the metadata
pseudo-record of instant ALTER TABLE, to leaves that split and merge while
a locking read walks them, and to a record that a remembered leaf supplies
for a scan that must then rebuild an older version of it. Two of the
tables scan a covering index, which reads a clustered record under an
exclusive select lock type and under a PAGE_MAX_TRX_ID that the read view
cannot see. clust_leaf_hint_off_debug runs the same body with the hints
turned off, through a debug switch that returns before a lookup tests or
refreshes the slots, so a diff of the two .result files is what the hints
save: 2006 to 1031 (2-level clustered index), 2291 to 1011 (3-level), 4006
to 2015 (two interleaved key ranges), 12016 to 9078 (locality in the
second half alone) and 20020 to 10045 for a covering scan that FOR UPDATE
makes non-covering, where the same scan without FOR UPDATE costs 20 in
both files. Two orders with too little locality to pay for the slots give
them up early and end within a hundred accesses of the unhinted count:
20020 to 19966 (decorrelated) and 20020 to 19999 (shuffled).

innodb.clust_leaf_hint_instant_alter covers the one rejection that no
count reaches, of a slot whose keys were copied under another
dict_index_t::n_core_fields than the index reports.
dict_index_t::clear_instant_alter() is the only writer of that value that
a shared metadata lock allows, and it needs the clustered index to lose
the last user record of its root page, while no leaf that is the root
fills a slot, so the tree has to shrink between the two, which purge does
there. The reader therefore reads uncommitted rows at READ UNCOMMITTED and
waits in a stored function while a rollback and purge take them away, and
one row that arrives above the position it stopped at is the lookup that
tests the slots. The rejection leaves nothing that a query can read, so
that branch writes the two counts to the error log under a debug switch
and the case reads them back with search_pattern_in_file.inc. They are
printed and not named in the pattern, so that a run which reaches the
branch with other counts, or in the direction where the clear lowers them,
is a difference to look at and not a pass.

main.rowid_filter_innodb: 90 to 88, and its ahi combination unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

4 participants