From 1cf73f34cdb8da309e2014eb97b437a216fd7480 Mon Sep 17 00:00:00 2001 From: Satya Bodapati Date: Wed, 2 Sep 2026 12:39:14 +0100 Subject: [PATCH] PXB-3862 : Merge scattered changed-page reads in page-tracking backups https://perconadev.atlassian.net/browse/PXB-3862 Problem ------- A page-tracking incremental reads only the pages the server marked as changed. Consecutive changed pages are already read together in one request; the problem is scattered changes. If the changed pages are 1, 3, 5, 7, 9 xtrabackup issues five single-page reads. Every read request costs a full I/O round trip, so with scattered changes the copy phase is bound by the number of requests instead of the amount of data, and an incremental can take several times longer than a full scan of the same file. Fix --- Read the whole range 1-9 in one request. The unchanged pages 2, 4, 6, 8 are read as filler and discarded by the existing incremental write filter, which drops every page whose FIL_PAGE_LSN is older than incremental_lsn. The backup's content and size are byte-for-byte unchanged; only the read pattern changes. A gap is worth combining across exactly when its bytes cost less than one read request. That cost, in bytes of sequential transfer, is round_trip * bandwidth - and no fixed number fits both a local NVMe and a network volume, so it is measured at backup start (probe_storage, xb_io_probe.h): twelve scattered single-page reads give the round trip, 16MB of sequential reads the bandwidth, on the largest changed data file (at least 64MB). Each sample region is first dropped from the OS page cache in whole 2MB-aligned units (newer kernels cache sequentially read files in blocks of up to 2MB and ignore a posix_fadvise(DONTNEED) that covers only part of a block), so a file warmed by a buffered server or a previous scan is still measured at device speed - while the copy itself keeps reading the warm file from RAM. Then read_request_cost = round_trip * bandwidth / 1.5, clamped to [64KB, 1MB], 512KB if unmeasurable merge_gap = read_request_cost / physical_page_size The /1.5 margin absorbs copy-pipeline overhead and measurement noise, erring toward reading less; it was calibrated on two instrumented machines whose break-evens bound it from both sides. The per-tablespace conversion lets compressed tablespaces combine across the same byte cost. Filler bytes are always the actual gap sizes present in the data, never the limit, so a generous limit reads nothing extra. --page-tracking-merge-gap exposes the behaviour: "auto" (default) as described; a page count pins one value for all tables and skips the probe; 0 keeps the previous strict-consecutive reads. Log messages ------------ Once per backup, the measurement: pagetracking: calibrated storage (./test/t1.ibd): request round trip 124 us, sequential read 984 MB/s -> one read request costs ~83KB of sequential transfer; gaps cheaper than this are combined Per table with at least 1000 changed pages, when its copy finishes, accumulated from what was actually read: pagetracking: test/t1.ibd: 3196 changed pages in 3196 ranges (avg gap 2.0 pages); merge-gap=4 (auto) combined them into 2 reads: request reduction 1598.0x, read amplification 2.98x; issued 16 read batches "ranges" is the requests merge-gap=0 would issue; "request reduction" is the benefit and "read amplification" its price (bytes read divided by changed bytes - read volume only, backup size is unaffected); "issued" exceeds the group count only when a group is larger than --read-buffer-size and is read in buffer-sized pieces. When the typical gap costs more than one read request, an extra line names both numbers and the pinned value to try, so a boundary case is diagnosable from the log alone: pagetracking: test/t1.ibd: typical gap 8.9 pages (143KB) costs more than one read request (83KB); reads stay individual - if sequential read throughput is high, --page-tracking-merge-gap=9 may be faster Testing: unit tests (xb_page_group-t) cover the read request cost model across device classes and the storage probe's failure modes; a framework testcase sweeps change densities and asserts only hardware-independent invariants - ranges vs combined reads vs issued requests, auto vs strict, the cost floor and ceiling, and restore correctness - and was verified to fail against a build with the combining silently disabled. --- storage/innobase/xtrabackup/CMakeLists.txt | 22 ++ .../xtrabackup/src/changed_page_tracking.cc | 54 +++- .../xtrabackup/src/changed_page_tracking.h | 20 +- storage/innobase/xtrabackup/src/fil_cur.cc | 2 +- storage/innobase/xtrabackup/src/read_filt.cc | 141 +++++++- storage/innobase/xtrabackup/src/read_filt.h | 63 +++- storage/innobase/xtrabackup/src/xb_io_probe.h | 277 ++++++++++++++++ storage/innobase/xtrabackup/src/xtrabackup.cc | 119 +++++++ storage/innobase/xtrabackup/src/xtrabackup.h | 3 + .../pagetracking/xb_pagetracking_merge_gap.sh | 305 ++++++++++++++++++ unittest/gunit/innodb/CMakeLists.txt | 1 + unittest/gunit/innodb/xb_page_group-t.cc | 121 +++++++ 12 files changed, 1104 insertions(+), 24 deletions(-) create mode 100644 storage/innobase/xtrabackup/src/xb_io_probe.h create mode 100644 storage/innobase/xtrabackup/test/suites/pagetracking/xb_pagetracking_merge_gap.sh create mode 100644 unittest/gunit/innodb/xb_page_group-t.cc diff --git a/storage/innobase/xtrabackup/CMakeLists.txt b/storage/innobase/xtrabackup/CMakeLists.txt index 39e8a968c14a..e4a6810d00cf 100644 --- a/storage/innobase/xtrabackup/CMakeLists.txt +++ b/storage/innobase/xtrabackup/CMakeLists.txt @@ -44,3 +44,25 @@ ENDIF() ADD_CUSTOM_TARGET(link_test_dir ALL COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/test" "${CMAKE_CURRENT_BINARY_DIR}/test") + +# PXB-3862: the server's unittest tree is disabled in XtraBackup builds +# (top-level gates on "WITH_UNIT_TESTS AND NOT WITH_XTRABACKUP"), so build +# the page-grouping / storage-probe unit tests here, self-contained +# against the bundled googletest. The tested code is header-only +# (xb_page_group.h, xb_io_probe.h), so no server libraries are needed. +IF(WITH_UNIT_TESTS) + FILE(GLOB XB_GTEST_ROOT + ${CMAKE_SOURCE_DIR}/extra/googletest/googletest-release-*/googletest) + ADD_EXECUTABLE(xb_page_group-t + ${CMAKE_SOURCE_DIR}/unittest/gunit/innodb/xb_page_group-t.cc + ${XB_GTEST_ROOT}/src/gtest-all.cc + ${XB_GTEST_ROOT}/src/gtest_main.cc) + TARGET_INCLUDE_DIRECTORIES(xb_page_group-t PRIVATE + ${CMAKE_SOURCE_DIR} ${XB_GTEST_ROOT}/include ${XB_GTEST_ROOT}) + SET_TARGET_PROPERTIES(xb_page_group-t PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/runtime_output_directory) + TARGET_COMPILE_OPTIONS(xb_page_group-t PRIVATE -Wno-undef) + FIND_PACKAGE(Threads REQUIRED) + TARGET_LINK_LIBRARIES(xb_page_group-t Threads::Threads) + ADD_TEST(NAME xb_page_group COMMAND xb_page_group-t) +ENDIF() diff --git a/storage/innobase/xtrabackup/src/changed_page_tracking.cc b/storage/innobase/xtrabackup/src/changed_page_tracking.cc index b2f52db56894..76b256cbe552 100644 --- a/storage/innobase/xtrabackup/src/changed_page_tracking.cc +++ b/storage/innobase/xtrabackup/src/changed_page_tracking.cc @@ -22,6 +22,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA #include "backup_mysql.h" #include "common.h" #include "components/mysqlbackup/backup_comp_constants.h" +#include "read_filt.h" #include "srv0srv.h" #include "xb0xb.h" #include "xtrabackup.h" @@ -268,10 +269,40 @@ bool is_component_installed(MYSQL *connection) { return mysql_component == 0 ? (false) : (true); } -void range_get_next_page(xb_page_set *page_set) { +/* Move current_page_it from the first changed page of the current read +range to its last: the caller then issues one pread covering the whole +range (sliced into --read-buffer-size pieces when larger). A gap is +the count of unchanged pages strictly BETWEEN two changed pages, not +their id difference: between changed pages 1 and 4 the gap is 2 +(pages 2 and 3). Gaps <= merge_gap are combined across, so nearby +changed pages form one larger sequential read instead of one read +request each; the gap pages become filler - read, then dropped by the +incremental write filter's LSN check: extra read volume, never backup +content. A gap > merge_gap ends the range; runs of consecutive changed +pages (gap 0) always stay whole. + + changed pages [1,3,6,9] (gaps 1,2,2): + merge_gap=0: 4 reads: [1] [3] [6] [9] + merge_gap=2: 1 read: [1-9] (filler 2,4,5,7,8 read, dropped) + +Counting: the loop inspects one neighbouring pair per iteration and +refuses (breaks) BEFORE the counting code, so reaching that code means +the pair's gap was just combined: its pages are added to filler_pages +right there. A refused gap becomes the space between two ranges and is +counted as skipped pages by the caller (rf_page_tracking_get_next_batch) +instead - each gap lands in exactly one of the two. Worked trace on the +shared example in read_filt.h (changed pages 1,2,3,4,7,20,21, +merge_gap=4): (4,7) gap 2 -> filler_pages += 2, combined_gaps = 1; +(7,20) gap 12 -> park on 7, range [1-7] ends; the caller then counts +20 - 8 = 12 skipped when the next call builds [20-21]. + +merge_gap comes from --page-tracking-merge-gap: by default ("auto") the +storage's measured read request cost converted to pages of this +tablespace's physical page size (see xb_io_probe.h). */ +void range_get_next_page(xb_page_set *page_set, xb_read_filt_ctxt_t *ctxt) { ut_ad(page_set->current_page_it != page_set->pages.end()); + const ulint merge_gap = ctxt->merge_gap; - /* loop to find the non continuous page id or end of block */ while (true) { auto current_page = *page_set->current_page_it; page_set->current_page_it++; @@ -280,18 +311,19 @@ void range_get_next_page(xb_page_set *page_set) { break; } auto next_page = *page_set->current_page_it; - if (next_page != current_page + 1) { - /* The next changed page is not adjacent to the current block. Step - back so that the iterator points to the last page of the current - contiguous block, as documented in the function comment. Leaving the - iterator on the next (non-adjacent) changed page makes the caller - extend the read batch up to that page, silently reading all the - unmodified pages in between. For large tablespaces with sparsely - distributed changed pages this degenerates into reading almost the - entire data file, defeating the purpose of page tracking. (PXB-3853) */ + ut_ad(next_page > current_page); + if (next_page > current_page + 1 + merge_gap) { + /* gap too large to combine across: park the iterator on the last + page of the current range so the read batch ends there */ --page_set->current_page_it; break; } + if (next_page > current_page + 1) { + /* past the refusal check, so this gap of >= 1 unchanged pages was + just combined into the range: its pages are filler */ + ctxt->stat_filler_pages += next_page - current_page - 1; + ctxt->stat_combined_gaps++; + } } } diff --git a/storage/innobase/xtrabackup/src/changed_page_tracking.h b/storage/innobase/xtrabackup/src/changed_page_tracking.h index 03d1b870f65c..2f373f274744 100644 --- a/storage/innobase/xtrabackup/src/changed_page_tracking.h +++ b/storage/innobase/xtrabackup/src/changed_page_tracking.h @@ -26,6 +26,10 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA #include "common.h" #include "mysql.h" +/* the read-filter context (read_filt.h includes this header, so only a +declaration is possible here) */ +struct xb_read_filt_ctxt_t; + namespace pagetracking { typedef std::set::iterator page_iterator; @@ -63,9 +67,19 @@ void deinit(xb_space_map *space_map); return true if installed */ bool is_component_installed(MYSQL *connection); -/** Move the current_page_it iterator to poin the last page id in current block -@param[in/out] page_set page_set */ -void range_get_next_page(xb_page_set *page_set); +/** Move the current_page_it iterator to point to the last page id of the +current block. Changed pages separated by gaps of at most merge_gap +unchanged pages belong to the same block, so that they are read with one +sequential read. +@param[in/out] page_set page_set +@param[in/out] ctxt read-filter context: ctxt->merge_gap bounds + the gaps merged into this block (in pages + of the tablespace's physical page size); + ctxt->stat_filler_pages and + ctxt->stat_combined_gaps accumulate the + unchanged pages inside merged gaps and the + number of gaps merged */ +void range_get_next_page(xb_page_set *page_set, xb_read_filt_ctxt_t *ctxt); /** Set the backupid @param[in] connection MySQL connection handler diff --git a/storage/innobase/xtrabackup/src/fil_cur.cc b/storage/innobase/xtrabackup/src/fil_cur.cc index db04712ee947..1c3464f2bcb6 100644 --- a/storage/innobase/xtrabackup/src/fil_cur.cc +++ b/storage/innobase/xtrabackup/src/fil_cur.cc @@ -430,7 +430,7 @@ void xb_fil_cur_close( xb_fil_cur_t *cursor) /*!< in/out: source file cursor */ { if (cursor->read_filter) { - cursor->read_filter->deinit(&cursor->read_filter_ctxt); + cursor->read_filter->deinit(cursor); } ut::free(cursor->scratch); diff --git a/storage/innobase/xtrabackup/src/read_filt.cc b/storage/innobase/xtrabackup/src/read_filt.cc index 80f55c24f627..8eb2cad3cf65 100644 --- a/storage/innobase/xtrabackup/src/read_filt.cc +++ b/storage/innobase/xtrabackup/src/read_filt.cc @@ -23,10 +23,15 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA /* Data file read filter implementation */ #include "read_filt.h" + +#include +#include #include "common.h" #include "dict0dict.h" #include "fil_cur.h" +#include "utils.h" #include "xb0xb.h" +#include "xb_io_probe.h" #include "xtrabackup.h" /****************************************************************/ /** @@ -42,6 +47,14 @@ static void common_init( ctxt->buffer_capacity = cursor->buf_size; ctxt->page_size = cursor->page_size; ctxt->space_id = cursor->space_id; + ctxt->merge_gap = 0; + ctxt->stat_batches = 0; + ctxt->stat_total_changed_pages = 0; + ctxt->stat_groups = 0; + ctxt->stat_combined_gaps = 0; + ctxt->stat_filler_pages = 0; + ctxt->stat_skipped_pages = 0; + ctxt->log_stats = false; } /****************************************************************/ /** @@ -99,8 +112,8 @@ static void rf_pass_through_get_next_batch( Deinitialize the pass-through read filter. */ static void rf_pass_through_deinit( /*===================*/ - xb_read_filt_ctxt_t *ctxt __attribute__((unused))) -/*!filter_batch_end = 0; + + /* Full-scan spaces never consult merge_gap; spaces without changed pages + are not read at all. */ + if (space_id == dict_sys_t::s_dict_space_id || + full_scan_tables.find(space_id) != full_scan_tables.end() || + changed_page_tracking == nullptr || + changed_page_tracking->count(space_id) == 0) { + return; + } + + /* A pinned --page-tracking-merge-gap is expressed in innodb_page_size + pages; scale it by the physical page size so compressed tablespaces + (zip size 1K-8K) combine across the same byte limit instead of a + proportionally smaller one. In auto mode (the default) the limit is + the read request cost - measured single-threaded at backup start, or + the conservative fallback - converted to pages of this tablespace's + physical page size: combine every gap cheaper than one saved read. + + The divisibility below holds for every InnoDB tablespace this filter + can see: the page-tracking filter is only selected for spaces in the + server's changed-page map (InnoDB by construction), and every valid + physical page size - 1K-16K compressed, up to the 64K server page + size uncompressed - is a power of two not larger than UNIV_PAGE_SIZE. + Were it ever violated in a release build, the integer arithmetic + degrades toward merge_gap = 0, i.e. the old strictly-consecutive reads: + a performance fallback, never a correctness risk (the incremental + write filter still gates every page by its LSN). */ + ut_ad(UNIV_PAGE_SIZE % ctxt->page_size == 0); + ctxt->merge_gap = + static_cast(opt_page_tracking_merge_gap_auto + ? xb_read_request_cost / ctxt->page_size + : uint64_t{opt_page_tracking_merge_gap} * + (UNIV_PAGE_SIZE / ctxt->page_size)); + + /* grouping statistics accumulate while the file is read and are + logged when it closes; below ~16MB of changed pages even fully + scattered reads cost a fraction of a second, so such tables only + add noise */ + constexpr ulint LOG_MIN_CHANGED_PAGES = 1000; + ctxt->stat_total_changed_pages = + changed_page_tracking->at(space_id).pages.size(); + ctxt->log_stats = (ctxt->stat_total_changed_pages >= LOG_MIN_CHANGED_PAGES); } /** Get the next batch of pages for the page tracking based filter. @@ -210,9 +265,21 @@ static void rf_page_tracking_get_next_batch(xb_fil_cur_t *cursor, verify_skipped_pages(); #endif + /* stats: one more read range. The gap between the previous + range's end and this one (if any) is exactly a gap the walker + refused (that refusal is what ended the previous range), so its + pages were seeked past, never read: skipped. Gaps the walker + combines lie inside a range and are counted as filler by the + walker itself - each gap lands in exactly one of the two. */ + ctxt->stat_groups++; + if (ctxt->filter_batch_end != 0) { + ctxt->stat_skipped_pages += next_page_id - ctxt->filter_batch_end; + } + ctxt->offset = next_page_id * ctxt->page_size; - /* Find the end of the current page tracking block */ - pagetracking::range_get_next_page(space); + /* Find the end of the current page tracking block; the walker + adds the pages it merges across into ctxt's stat counters */ + pagetracking::range_get_next_page(space, ctxt); ut_ad(space->current_page_it != space->pages.end()); ctxt->filter_batch_end = (*space->current_page_it) + 1; @@ -236,15 +303,73 @@ static void rf_page_tracking_get_next_batch(xb_fil_cur_t *cursor, *read_batch_len = ctxt->buffer_capacity; } + if (*read_batch_len > 0) { + ctxt->stat_batches++; + } + ut_ad(ctxt->offset % ctxt->page_size == 0); ut_ad(*read_batch_start % ctxt->page_size == 0); ut_ad(*read_batch_len % ctxt->page_size == 0); } -/** Deinitialize the page tracking based read filter. -@param[in] ctxt read filtr context */ -static void rf_page_tracking_deinit(xb_read_filt_ctxt_t *ctxt - __attribute__((unused))) {} +/** Deinitialize the page tracking based read filter: log the grouping +statistics accumulated while the file was read. Everything reported +here describes what actually happened - no prediction. */ +static void rf_page_tracking_deinit(xb_fil_cur_t *cursor) { + const xb_read_filt_ctxt_t *ctxt = &cursor->read_filter_ctxt; + if (!ctxt->log_stats || ctxt->stat_groups == 0) { + return; + } + + /* ranges = runs of consecutive changed pages: every combined gap + joined two of them into one read */ + const ulint ranges = ctxt->stat_groups + ctxt->stat_combined_gaps; + const ulint boundaries = ranges - 1; + const double avg_gap = boundaries == 0 + ? 0.0 + : static_cast(ctxt->stat_filler_pages + + ctxt->stat_skipped_pages) / + static_cast(boundaries); + /* the benefit and its price, as parallel ratios: how many times fewer + read requests, bought at how many times the changed read volume */ + const double reduction = + static_cast(ranges) / static_cast(ctxt->stat_groups); + const double amplification = + static_cast(ctxt->stat_total_changed_pages + + ctxt->stat_filler_pages) / + static_cast(ctxt->stat_total_changed_pages); + xb::info() << std::fixed << "pagetracking: " << cursor->rel_path << ": " + << ctxt->stat_total_changed_pages << " changed pages in " << ranges + << " ranges (avg gap " << std::setprecision(1) << avg_gap + << " pages); merge-gap=" << ctxt->merge_gap + << (opt_page_tracking_merge_gap_auto ? " (auto)" : "") + << " combined them into " << ctxt->stat_groups + << " reads: request reduction " << reduction + << "x, read amplification " << std::setprecision(2) + << amplification << "x; issued " << ctxt->stat_batches + << " read batches"; + + /* Make an ineffective auto decision self-explanatory: when the + typical gap costs more than one read request, combining achieves + little and the reads stay individual. Only when the gap is within + reach of a plausible cost: past the clamp ceiling no calibration + could ever combine across it - that is genuinely sparse data. */ + const uint64_t avg_gap_bytes = + static_cast(avg_gap * static_cast(ctxt->page_size)); + if (opt_page_tracking_merge_gap_auto && reduction < 1.5 && + avg_gap_bytes > xb_read_request_cost && + avg_gap_bytes <= pagetracking::READ_REQUEST_COST_MAX_BYTES) { + xb::info() << std::fixed << std::setprecision(1) + << "pagetracking: " << cursor->rel_path << ": typical gap " + << avg_gap << " pages (" + << xtrabackup::utils::human_readable(avg_gap_bytes) + << ") costs more than one read request (" + << xtrabackup::utils::human_readable(xb_read_request_cost) + << "); reads stay individual - if sequential read " + "throughput is high, --page-tracking-merge-gap=" + << static_cast(avg_gap + 1.0) << " may be faster"; + } +} /* The pass-through read filter */ xb_read_filt_t rf_pass_through = {&rf_pass_through_init, diff --git a/storage/innobase/xtrabackup/src/read_filt.h b/storage/innobase/xtrabackup/src/read_filt.h index 5b81877cf63a..36001eaeac98 100644 --- a/storage/innobase/xtrabackup/src/read_filt.h +++ b/storage/innobase/xtrabackup/src/read_filt.h @@ -39,6 +39,65 @@ struct xb_read_filt_ctxt_t { ulint filter_batch_end; /*!< the ending page id of the current changed page block in the page tracking */ + ulint merge_gap; /*!< largest changed-page gap to + combine into one read, in pages of + page_size (--page-tracking-merge-gap + scaled for compressed tablespaces, + or the read request cost in + pages) */ + /* Statistics accumulated while the file is read, reported by the + filter's deinit in one log line; they never influence any decision. + + Shared example used in the field comments below: changed pages + + 1,2,3,4 7 20,21 (3 runs of consecutive pages) + + with merge_gap = 4. A gap counts the unchanged pages BETWEEN two + changed pages (between 4 and 7 it is 2: pages 5,6 - not the id + difference 3). The gap of 2 (pages 5,6) is combined across, so + [1-7] becomes one read group; the gap of 12 (pages 8..19) is not, + so [20-21] starts a second group. Every unchanged page between two + changed pages ends up in exactly one of stat_filler_pages (its gap + was combined: the page was read) or stat_skipped_pages (its gap was + refused: the page was seeked past). */ + ulint stat_batches; /*!< pread requests actually issued. + Example: 2 - one per group; exceeds + stat_groups only when a group is + larger than --read-buffer-size and is + read in buffer-sized sequential + pieces */ + ulint stat_total_changed_pages; /*!< pages the server tracked as + changed for this file: the work that + must be copied regardless of + grouping. Example: 7 + (1,2,3,4,7,20,21) */ + ulint stat_groups; /*!< read groups formed = distinct + disk locations read = seeks. + Example: 2 ([1-7] and [20-21]). The + log line's "ranges" = stat_groups + + stat_combined_gaps is what merge-gap=0 + would have read: here 3, so combining + saved one request */ + ulint stat_combined_gaps; /*!< gaps of unchanged pages combined + across (gap <= merge_gap), each joining + two ranges into one read. Example: 1 + (the 2-page gap between 4 and 7) */ + ulint stat_filler_pages; /*!< unchanged pages read only as + filler inside combined gaps, then + dropped by the incremental write + filter's LSN check - they cost read + volume only, never backup size. + Example: 2 (pages 5,6). read + amplification = (total_changed + + filler) / total_changed */ + ulint stat_skipped_pages; /*!< unchanged pages in refused gaps + (gap > merge_gap): never read, seeked + past. Example: 12 (pages 8..19). + avg gap = (filler + skipped) / + (ranges - 1) describes how scattered + the changes are */ + bool log_stats; /*!< log grouping and batch stats + for this file */ }; /* The read filter */ @@ -47,7 +106,9 @@ struct xb_read_filt_t { ulint space_id); void (*get_next_batch)(xb_fil_cur_t *ctxt, uint64_t *read_batch_start, uint64_t *read_batch_len); - void (*deinit)(xb_read_filt_ctxt_t *ctxt); + /* called from xb_fil_cur_close with the owning cursor, so the + filter can report per-file statistics with full cursor context */ + void (*deinit)(xb_fil_cur_t *cursor); void (*update)(xb_read_filt_ctxt_t *ctxt, uint64_t len, const xb_fil_cur_t *cursor); }; diff --git a/storage/innobase/xtrabackup/src/xb_io_probe.h b/storage/innobase/xtrabackup/src/xb_io_probe.h new file mode 100644 index 000000000000..208cc5e4b555 --- /dev/null +++ b/storage/innobase/xtrabackup/src/xb_io_probe.h @@ -0,0 +1,277 @@ +/****************************************************** +Copyright (c) 2026 Percona LLC and/or its affiliates. + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; version 2 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +*******************************************************/ + +/* Storage probe for --page-tracking-merge-gap=auto (PXB-3862). + +Measures the two device characteristics the read request cost needs +(see read_request_cost_bytes() below): the sequential read bandwidth, +from a few large contiguous reads, then the per-request round trip, +from single-page reads scattered across the file. ~28 reads / ~16MB +total, well under a second on any storage. + +Plain POSIX on purpose: no server I/O layer, no xtrabackup globals, so +the probe is unit-testable standalone and can be pointed at any file - +including a live mysqld datadir - by the env-gated gunit case in +unittest/gunit/innodb/xb_page_group-t.cc. */ + +#ifndef XB_IO_PROBE_H +#define XB_IO_PROBE_H + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace pagetracking { + +/** Files smaller than this cannot be measured meaningfully (the +sequential samples would not reach steady state); the probe rejects +them and the caller keeps the fallback read request cost. */ +constexpr uint64_t PROBE_MIN_FILE_BYTES = 64 * 1024 * 1024; + +/** Result of probe_storage(). */ +struct Probe_result { + uint64_t rtt_us{0}; /*!< per-request round trip, median */ + uint64_t bw_bytes_per_sec{0}; /*!< sequential read bandwidth */ +}; + +/** Drop a byte range of the file from the OS page cache, so the probe +times the device rather than RAM (the range may be warm from the server +or a previous scan). Newer kernels cache a sequentially read file in +blocks of up to 2MB and ignore the advice unless it covers a whole +block, so the range is widened to full 2MB multiples. The advice skips +dirty pages; under O_DIRECT it is a no-op. + +@param[in] fd file the probe reads from +@param[in] offset start of the byte range about to be read +@param[in] length length of that range */ +inline void evict_from_page_cache([[maybe_unused]] int fd, + [[maybe_unused]] uint64_t offset, + [[maybe_unused]] uint64_t length) { +#ifdef POSIX_FADV_DONTNEED + constexpr uint64_t EVICT_SIZE_BYTES = 2 * 1024 * 1024; + /* the file is a row of 2MB units; dividing a byte position by the + unit size gives the number of the unit it falls in. Evict every unit + the range touches, from the one holding its first byte to the one + holding its last byte, in full. */ + const uint64_t first_unit = offset / EVICT_SIZE_BYTES; + const uint64_t last_unit = (offset + length - 1) / EVICT_SIZE_BYTES; + ::posix_fadvise(fd, first_unit * EVICT_SIZE_BYTES, + (last_unit - first_unit + 1) * EVICT_SIZE_BYTES, + POSIX_FADV_DONTNEED); +#endif +} + +/** Measure the storage behind a file. + +Every read below fetches whole 16KB pages at page-aligned offsets - the +same shape as the copy loop's reads, and aligned as O_DIRECT requires. +One file descriptor serves both phases: pread carries no file position, +and closing/reopening between them would evict nothing anyway (the OS +page cache is keyed by inode, and the device's internal cache is beyond +user space either way). + +The sequential phase runs FIRST, on pages nothing has touched yet, so +its bandwidth cannot be inflated by caching; any cross-phase cache +effect can then only make the later scattered reads faster, and +understating the round trip shrinks the resulting cost - the safe +direction. + +@param[in] path file to probe (a large data file of the backup + source, so the numbers describe the same device + the copy will read) +@param[in] use_o_direct open with O_DIRECT, matching how the backup + will read the data files; without it, page + cache hits can understate the round trip (the + resulting cost is then smaller - the safe + direction) +@return measured characteristics, or std::nullopt when the file cannot + be opened, is smaller than PROBE_MIN_FILE_BYTES, or a read + fails */ +inline std::optional probe_storage(const char *path, + bool use_o_direct) { + using clock = std::chrono::steady_clock; + using std::chrono::duration_cast; + using std::chrono::microseconds; + + constexpr uint64_t PROBE_PAGE_BYTES = 16 * 1024; + constexpr int RTT_SAMPLES = 12; + constexpr uint64_t SEQ_CHUNK_BYTES = 4 * 1024 * 1024; + constexpr int SEQ_SAMPLES = 4; + constexpr size_t BUF_ALIGN = 4096; /* covers any O_DIRECT block size */ + + int flags = O_RDONLY; +#ifdef O_DIRECT + if (use_o_direct) { + flags |= O_DIRECT; + } +#endif + + const int fd = ::open(path, flags); + if (fd < 0) { + return (std::nullopt); + } + + struct stat file_stat; + if (::fstat(fd, &file_stat) != 0 || + static_cast(file_stat.st_size) < PROBE_MIN_FILE_BYTES) { + ::close(fd); + return (std::nullopt); + } + const uint64_t file_pages = + static_cast(file_stat.st_size) / PROBE_PAGE_BYTES; + + void *buf = nullptr; + if (::posix_memalign(&buf, BUF_ALIGN, SEQ_CHUNK_BYTES) != 0) { + ::close(fd); + return (std::nullopt); + } + + /* contiguous pages from the middle of the file onward -> bandwidth + (PROBE_MIN_FILE_BYTES guarantees the chunks fit after the midpoint) */ + Probe_result result; + evict_from_page_cache(fd, (file_pages / 2) * PROBE_PAGE_BYTES, + SEQ_SAMPLES * SEQ_CHUNK_BYTES); + uint64_t seq_page_no = file_pages / 2; + uint64_t seq_bytes = 0; + const auto seq_start = clock::now(); + for (int i = 0; i < SEQ_SAMPLES; i++) { + const ssize_t n_read = + ::pread(fd, buf, SEQ_CHUNK_BYTES, seq_page_no * PROBE_PAGE_BYTES); + if (n_read <= 0) { + break; + } + seq_bytes += n_read; + seq_page_no += n_read / PROBE_PAGE_BYTES; + } + /* the max() makes the division below total; sub-microsecond timing + of a 16MB read cannot happen on real hardware */ + const uint64_t seq_us = std::max( + duration_cast(clock::now() - seq_start).count(), 1); + if (seq_bytes == 0) { + ::free(buf); + ::close(fd); + return (std::nullopt); + } + result.bw_bytes_per_sec = seq_bytes * 1000000 / seq_us; + + /* one page read at every 1/13th of the file -> round trip: each read + fetches page (file_pages / 13) * i, mirroring how the copy loop + fetches one isolated changed page. The median is used so a stray + stall or cache hit cannot skew the estimate. */ + std::vector rtt_samples; + for (int i = 1; i <= RTT_SAMPLES; i++) { + const uint64_t page_no = (file_pages / (RTT_SAMPLES + 1)) * i; + evict_from_page_cache(fd, page_no * PROBE_PAGE_BYTES, PROBE_PAGE_BYTES); + const auto start = clock::now(); + if (::pread(fd, buf, PROBE_PAGE_BYTES, page_no * PROBE_PAGE_BYTES) != + static_cast(PROBE_PAGE_BYTES)) { + ::free(buf); + ::close(fd); + return (std::nullopt); + } + rtt_samples.push_back( + duration_cast(clock::now() - start).count()); + } + std::nth_element(rtt_samples.begin(), + rtt_samples.begin() + rtt_samples.size() / 2, + rtt_samples.end()); + /* a cache-served page read takes under a microsecond and truncates to + 0; floor it to 1 so the round trip stays a meaningful, nonzero value + (nothing divides by it - understating it only shrinks the cost, the + safe direction) */ + result.rtt_us = std::max(rtt_samples[rtt_samples.size() / 2], 1); + + ::free(buf); + ::close(fd); + return (result); +} + +/** clamp bounds for the measured read request cost */ +constexpr uint64_t READ_REQUEST_COST_MIN_BYTES = 64 * 1024; +constexpr uint64_t READ_REQUEST_COST_MAX_BYTES = 1024 * 1024; + +/** Fallback read request cost when the storage could not be probed: a +conservative cut across common storage classes. */ +constexpr uint64_t READ_REQUEST_COST_FALLBACK_BYTES = 512 * 1024; + +/** Safety margin dividing the raw break-even (rtt * bandwidth). + +A margin is needed because the probe measures raw device bandwidth, +while merged filler bytes also flow through the copy pipeline +(checksum, buffer management) whose effective bandwidth is lower, and +because the risk is asymmetric: too large a cost makes backups slower +than unmerged reads (a regression), too small only misses part of the +win. + +1.5 was calibrated on two instrumented machines whose measured +break-evens bound it from both sides: a shared-disk NVMe (raw 171KB, +true break-even ~131KB: merging across 144KB gaps measurably loses) +needs a margin >= ~1.3, while a split-data-and-backup-disk server +(raw ~245KB, effective ~= raw: merging across 144KB gaps measurably +wins) needs the cost to stay above 144KB, i.e. a margin <= ~1.7. +1.5 satisfies both with headroom; 2 was over-conservative and refused +profitable merges on the split-disk machine. */ +constexpr double READ_REQUEST_COST_MARGIN = 1.5; + +/** Compute the read request cost: what one read request costs the +backup, expressed in bytes of sequential transfer. It bounds gap +merging - the most bytes worth reading across one gap of unchanged +pages to save one read request - and is derived from measured storage +characteristics. + +Merging across a gap saves one request round trip (rtt) and costs +gap_bytes / bandwidth of transfer, so the raw break-even is +rtt * bandwidth, reduced by READ_REQUEST_COST_MARGIN (see there). + +Reference points: local NVMe 0.15ms x 1.2GB/s -> ~115KB (refuses the +merges that regress there); split-disk server 0.165ms x 1.5GB/s -> +~165KB (merges the 144KB gaps that win there); ~1ms cloud volume x +400MB/s -> ~250KB; HDD 8ms x 150MB/s -> ~800KB. + +@param[in] rtt_us measured per-request round trip, microseconds +@param[in] bw_bytes_per_sec measured sequential read bandwidth +@return read request cost in bytes, clamped to + [READ_REQUEST_COST_MIN_BYTES, READ_REQUEST_COST_MAX_BYTES] */ +inline uint64_t read_request_cost_bytes(uint64_t rtt_us, + uint64_t bw_bytes_per_sec) { + const double rtt_seconds = static_cast(rtt_us) / 1000000.0; + /* the bytes one round trip's worth of sequential transfer moves, + reduced by the safety margin */ + const uint64_t raw = + static_cast(static_cast(bw_bytes_per_sec) * + rtt_seconds / READ_REQUEST_COST_MARGIN); + if (raw < READ_REQUEST_COST_MIN_BYTES) { + return (READ_REQUEST_COST_MIN_BYTES); + } + if (raw > READ_REQUEST_COST_MAX_BYTES) { + return (READ_REQUEST_COST_MAX_BYTES); + } + return (raw); +} + +} // namespace pagetracking + +#endif /* XB_IO_PROBE_H */ diff --git a/storage/innobase/xtrabackup/src/xtrabackup.cc b/storage/innobase/xtrabackup/src/xtrabackup.cc index 317385cd9d91..365c4ca72389 100644 --- a/storage/innobase/xtrabackup/src/xtrabackup.cc +++ b/storage/innobase/xtrabackup/src/xtrabackup.cc @@ -122,6 +122,7 @@ Place, Suite 330, Boston, MA 02111-1307 USA #include "write_filt.h" #include "wsrep.h" #include "xb0xb.h" +#include "xb_io_probe.h" #include "xb_regex.h" #include "xbcrypt_common.h" #include "xbstream.h" @@ -459,6 +460,17 @@ static ulonglong global_max_value; bool opt_galera_info = false; bool opt_slave_info = false; bool opt_page_tracking = false; +/* --page-tracking-merge-gap: "auto" (default) chooses the gap per table +from the changed-page distribution; a number pins it for all tables. */ +bool opt_page_tracking_merge_gap_auto = true; +ulong opt_page_tracking_merge_gap = 0; +static char *opt_page_tracking_merge_gap_str = nullptr; +/* Read request cost for merge-gap=auto: what one read request costs in +bytes of sequential transfer, i.e. the most bytes worth reading across +one gap to save one request. Set once, single-threaded, before the +copy threads start (see xb_probe_read_request_cost()); read-only +after. */ +uint64_t xb_read_request_cost = pagetracking::READ_REQUEST_COST_FALLBACK_BYTES; bool opt_no_lock = false; bool opt_safe_slave_backup = false; bool opt_rsync = false; @@ -782,6 +794,7 @@ enum options_xtrabackup { OPT_MOVE_BACK, OPT_GALERA_INFO, OPT_PAGE_TRACKING, + OPT_PAGE_TRACKING_MERGE_GAP, OPT_SLAVE_INFO, OPT_NO_LOCK, OPT_LOCK_DDL, @@ -1111,6 +1124,19 @@ struct my_option xb_client_options[] = { (uchar *)&opt_page_tracking, (uchar *)&opt_page_tracking, 0, GET_BOOL, NO_ARG, 0, 0, 0, 0, 0, 0}, + {"page-tracking-merge-gap", OPT_PAGE_TRACKING_MERGE_GAP, + "with --page-tracking, the maximum gap of unchanged pages, between " + "two changed pages, across which the reads are merged into one " + "continuous read. Merging avoids many small individual reads when " + "changed pages are scattered; the gap pages are read but never " + "written to the backup, so this affects read volume only, not backup " + "size. The default \"auto\" sizes the gap to the backup storage; a " + "number (in innodb_page_size pages) sets the largest merged gap for " + "all tables; 0 disables merging.", + (uchar *)&opt_page_tracking_merge_gap_str, + (uchar *)&opt_page_tracking_merge_gap_str, 0, GET_STR, REQUIRED_ARG, 0, 0, + 0, 0, 0, 0}, + {"no-lock", OPT_NO_LOCK, "Use this option to disable lock-ddl and table lock " "with \"FLUSH TABLES WITH READ LOCK\". Use it only if ALL your " @@ -1975,6 +2001,25 @@ bool xb_get_one_option(int optid, const struct my_option *opt, char *argument) { ADD_PRINT_PARAM_OPT(opt_mysql_tmpdir); break; + case OPT_PAGE_TRACKING_MERGE_GAP: { + if (strcasecmp(argument, "auto") == 0) { + opt_page_tracking_merge_gap_auto = true; + break; + } + char *endp = nullptr; + errno = 0; + ulonglong val = strtoull(argument, &endp, 10); + if (endp == argument || *endp != '\0' || errno == ERANGE || val > 65536) { + xb::error() << "invalid --page-tracking-merge-gap value " + << SQUOTE(argument) + << ". Expected \"auto\" or a page count 0..65536"; + return 1; + } + opt_page_tracking_merge_gap_auto = false; + opt_page_tracking_merge_gap = static_cast(val); + break; + } + case OPT_INNODB_DATA_HOME_DIR: ADD_PRINT_PARAM_OPT(innobase_data_home_dir); @@ -4287,6 +4332,78 @@ static void cleanup_mysql_environment() { mysql_mutex_destroy(&LOCK_replica_list); } +/** With --page-tracking-merge-gap=auto, measure the storage once to set +the read request cost (see xb_io_probe.h). Probes the +largest changed data file of at least PROBE_MIN_FILE_BYTES - the most +representative of the reads the cost will govern. Must run +single-threaded, before the data copy threads start: xb_read_request_cost +is written once here and only read afterwards. Iterates all tablespaces +unfiltered (datafiles_iter_new(nullptr)): the changed-page map is the +only filter that matters for picking a probe candidate, and the +dd-validation pass would log its orphan warnings a second time. Every +candidate is an InnoDB tablespace by construction: the iterator walks +the InnoDB fil system only (files of other engines, MyRocks included, +never appear in it) and the changed-page map is keyed by InnoDB space +id. */ +static void xb_probe_read_request_cost() { + if (!opt_page_tracking_merge_gap_auto || changed_page_tracking == nullptr || + changed_page_tracking->empty()) { + return; + } + + char probe_path[FN_REFLEN] = ""; + uint64_t probe_size = 0; + + datafiles_iter_t *it = datafiles_iter_new(nullptr); + if (it == nullptr) { + return; + } + while (fil_node_t *node = datafiles_iter_next(it)) { + if (changed_page_tracking->count(node->space->id) == 0) { + continue; + } + /* node->size is in pages of the space's physical page size, set when + the node was opened during tablespace discovery; no syscall needed. + probe_storage() re-checks the real size after open, so a stale value + can only lead to the fallback cost, never to a wrong measurement. + (The iterator is sorted largest-first since PXB-3502; this running + max does not depend on that, or any, iteration order.) */ + const page_size_t node_page_size(node->space->flags); + const uint64_t node_bytes = + uint64_t{node->size} * node_page_size.physical(); + if (node_bytes > probe_size) { + probe_size = node_bytes; + snprintf(probe_path, sizeof(probe_path), "%s", node->name); + } + } + datafiles_iter_free(it); + + if (probe_size < pagetracking::PROBE_MIN_FILE_BYTES) { + return; /* nothing measurable; the fallback cost stays */ + } + + const bool use_o_direct = + srv_unix_file_flush_method == SRV_UNIX_O_DIRECT || + srv_unix_file_flush_method == SRV_UNIX_O_DIRECT_NO_FSYNC; + + const auto probe = pagetracking::probe_storage(probe_path, use_o_direct); + if (!probe.has_value()) { + return; + } + + xb_read_request_cost = pagetracking::read_request_cost_bytes( + probe->rtt_us, probe->bw_bytes_per_sec); + + xb::info() << "pagetracking: calibrated storage (" << probe_path + << "): request round trip " << probe->rtt_us + << " us, sequential read " + << xtrabackup::utils::human_readable(probe->bw_bytes_per_sec) + << "/s -> one read request costs ~" + << xtrabackup::utils::human_readable(xb_read_request_cost) + << " of sequential transfer; gaps cheaper than this are " + "combined"; +} + void xtrabackup_backup_func(void) { MY_STAT stat_info; uint i; @@ -4537,6 +4654,8 @@ void xtrabackup_backup_func(void) { << " threads for parallel data files transfer"; } + xb_probe_read_request_cost(); + auto it = datafiles_iter_new(xb_dd_spaces); if (it == NULL) { xb::error() << "datafiles_iter_new() failed."; diff --git a/storage/innobase/xtrabackup/src/xtrabackup.h b/storage/innobase/xtrabackup/src/xtrabackup.h index 96d8d29b3a49..4ae20262adff 100644 --- a/storage/innobase/xtrabackup/src/xtrabackup.h +++ b/storage/innobase/xtrabackup/src/xtrabackup.h @@ -180,6 +180,9 @@ extern longlong xtrabackup_use_memory; extern bool opt_galera_info; extern bool opt_slave_info; extern bool opt_page_tracking; +extern bool opt_page_tracking_merge_gap_auto; +extern ulong opt_page_tracking_merge_gap; +extern uint64_t xb_read_request_cost; extern bool opt_no_lock; extern bool opt_safe_slave_backup; extern bool opt_rsync; diff --git a/storage/innobase/xtrabackup/test/suites/pagetracking/xb_pagetracking_merge_gap.sh b/storage/innobase/xtrabackup/test/suites/pagetracking/xb_pagetracking_merge_gap.sh new file mode 100644 index 000000000000..fd3ffebb22eb --- /dev/null +++ b/storage/innobase/xtrabackup/test/suites/pagetracking/xb_pagetracking_merge_gap.sh @@ -0,0 +1,305 @@ +############################################################################ +# PXB-3862: --page-tracking-merge-gap groups changed pages into read ranges +# +# Plan: dirty a known fraction of one table's pages (15%, 25%, 50%), then +# take three incrementals from the same base at merge-gap = 0 / 65536 / auto +# and verify each against the two log lines the feature prints: +# +# pagetracking: test/t1.ibd: changed pages in ranges (avg gap +# pages); merge-gap= [(auto)] combined them into reads: ... +# -> the PREDICTION, computed from the changed-page set +# ... issued read batches (end of the same line) +# -> the ACTUAL read requests performed for the file +# +# Only hardware-independent facts are asserted: +# per run: merge-gap=0 -> nothing merged, and actual == predicted +# merge-gap=65536 -> one logical group (few actual reads) +# auto -> reported as (auto) +# cross-run: auto never issues more reads than strict; at 50% changed +# the gaps are ~1-2 pages, far below the 64KB cost FLOOR, +# so any measured cost combines them -> >= 4x fewer reads. +# (At 15%/25% the gap distribution's tail crosses the floor +# bound of 4 pages, so only the weak comparison is portable.) +# correctness: the restored auto backup equals the source +# ceiling: two regions > 1MB apart are never merged (cost CEILING) +# Deliberately NOT asserted: timings, absolute page counts, and auto's +# chosen gap at 15% (gaps ~5-6 pages sit in the zone where the measured +# cost may legitimately combine or refuse). +############################################################################ + +. inc/common.sh + +start_server + +############################################################################ +# Fixture: ~1M fixed-width rows, ~50 rows per 16KB page => ~21000 data +# pages (~320MB), loaded in PK order so page order follows id order. +# Sized so the sparsest sweep case stays well above the 1000-changed-page +# logging threshold: the id stride formula below delivers roughly half +# its nominal density in distinct pages (auto-increment holes), so the +# 15% dataset dirties ~1800 pages of this table - with a 160MB table it +# lands at ~900, under the threshold, and the grouping line never prints. +# Dirtying one row per K ids dirties roughly every (K/50)th page - a +# uniform stride whose gap we control through K. The file exceeds the +# probe's 64MB minimum, so auto runs exercise the measured read request +# cost rather than the fallback. +############################################################################ +vlog "load ~21000 pages of fixed-width rows (PK order == page order)" +mysql test </dev/null + +t1_space=$(mysql -Ns -e \ + "SELECT space FROM information_schema.innodb_tables WHERE name='test/t1'") +vlog "t1 space id: $t1_space" + +############################################################################ +# Deterministic changed-page sets: page tracking records a page when it +# is FLUSHED, so the map only equals the dataset if nothing else flushes +# while tracking runs. Draining the dirty pages before the base backup +# keeps the load's flush tail (a long contiguous run that a slow worker +# is still writing back) out of the map, and draining after the UPDATE +# gets every dirtied page tracked while the server is fully alive +# instead of relying on shutdown-time flushes being tracked. +############################################################################ +flush_dirty_pages() { + local dirty i + mysql -e "SET GLOBAL innodb_max_dirty_pages_pct_lwm = 0" + mysql -e "SET GLOBAL innodb_max_dirty_pages_pct = 0" + for i in $(seq 1 120); do + dirty=$(mysql -Ns -e "SELECT VARIABLE_VALUE \ + FROM performance_schema.global_status \ + WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty'") + [ "$dirty" -eq 0 ] && break + sleep 1 + done + [ "$dirty" -eq 0 ] || die "buffer pool did not drain: $dirty dirty pages" + mysql -e "SET GLOBAL innodb_max_dirty_pages_pct = DEFAULT" + mysql -e "SET GLOBAL innodb_max_dirty_pages_pct_lwm = DEFAULT" +} + +############################################################################ +# Log parsing helpers +############################################################################ + +# Extract one number from t1's grouping (prediction) line; $2 is a sed -E +# expression whose capture group selects the wanted field. +grouping_field() { # $1=log $2=sed-expr + grep -m1 "pagetracking: .*t1.ibd: .* changed pages" "$1" | sed -E "$2" +} + +# The read requests actually issued for t1: reported at the end of the +# grouping line, which prints when the file closes - every number in it +# describes what actually happened (counters accumulate during the copy). +issued_batches() { # $1=log + grouping_field "$1" 's/.*issued ([0-9]+) read batches.*/\1/' +} + +############################################################################ +# take_and_verify