disc/patch: adaptive batching — 32 sectors, drop to 1 on failure
Pass N now reads at 32 sectors per attempt and drops to 1 only on batch-read failure to probe each sector individually. After 16 consecutive clean single-sector reads it climbs back to 32. Net effect: NonTrimmed regions walk ~32x faster in clean stretches without sacrificing per-sector recovery quality — the drop-to-1 retry from the same cursor position guarantees every sector in a failed batch is individually attempted. Design contract: - A batch-read failure (count > 1) is NOT a recorded failure: no NonTrimmed mark, no consecutive_failures bump, no damage_window push, cursor stays put. We just drop current_batch to 1 and the loop re-attempts the same position at single-sector granularity. - A single-sector failure (count == 1) follows the existing path: NonTrimmed mark, consecutive_failures++, damage_window.push(false), post-failure pause, wedge probes. - Backtrack always at count=1: this path fills a gap that the main loop's damage-window skip jumped over. Using batched reads there would lump good sectors into NonTrimmed marks when the gap contains even one bad sector. State machine adds: - `initial_batch` (from opts.block_sectors, default 32 in patch_internal) - `current_batch` (mutable, starts at initial_batch, drops to 1 on batch failure) - `consecutive_singles_ok` (counter, resets on upscale + failure) - `ADAPTIVE_UPSCALE_THRESHOLD = 16` (matches sweep's pattern for "16 consecutive good = back to fast mode") Tests: - pass_n_size_aware_skip.rs PatternedSectorReader now fills each sector with its OWN LBA byte (not the starting LBA's byte). This matches real drive behavior — the pre-0.18.13 fixture's "fill whole batch with one byte" was a shortcut that only worked when patch read 1 sector at a time. Existing recovery-quality assertions all still pass under adaptive batching. User spec: "try 32, pass, great, fail -> do 1 sector"
This commit is contained in:
+89
-8
@@ -1390,7 +1390,15 @@ impl Disc {
|
|||||||
) -> Result<CopyResult> {
|
) -> Result<CopyResult> {
|
||||||
let patch_opts = PatchOptions {
|
let patch_opts = PatchOptions {
|
||||||
decrypt: opts.decrypt,
|
decrypt: opts.decrypt,
|
||||||
block_sectors: Some(1),
|
// 0.18.13: adaptive batching. patch() reads at 32 sectors
|
||||||
|
// when the drive is healthy, drops to 1 on failure to
|
||||||
|
// probe each sector individually, then climbs back after
|
||||||
|
// 16 consecutive clean singles. Walks NonTrimmed regions
|
||||||
|
// ~32x faster in clean stretches without sacrificing any
|
||||||
|
// per-sector recovery quality — the drop-to-1 retry from
|
||||||
|
// the same position guarantees every sector in a failed
|
||||||
|
// batch is individually probed. See Disc::patch body.
|
||||||
|
block_sectors: Some(32),
|
||||||
full_recovery: true,
|
full_recovery: true,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
wedged_threshold: 50,
|
wedged_threshold: 50,
|
||||||
@@ -2202,7 +2210,23 @@ impl Disc {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let block_sectors = opts.block_sectors.unwrap_or(1);
|
// Adaptive batching: read at `current_batch`, drop to 1 on
|
||||||
|
// batch-read failure, climb back to `initial_batch` after
|
||||||
|
// ADAPTIVE_UPSCALE_THRESHOLD consecutive single-sector successes.
|
||||||
|
// Rationale: dense damage scattered through a NonTrimmed range
|
||||||
|
// is rare — most "bad ranges" in pass N have lots of good
|
||||||
|
// sectors that swept-by-default landed inside. Batch reads
|
||||||
|
// walk those at ~32x the speed of singles, dropping to 1
|
||||||
|
// only when the drive actually returns an error. Guarantees:
|
||||||
|
// - no good sector is ever marked NonTrimmed because it
|
||||||
|
// was bundled in a failed batch — failed batches are
|
||||||
|
// "split decisions", not recorded failures
|
||||||
|
// - drop-to-1 retries the SAME starting position, so every
|
||||||
|
// sector in the failed batch is individually probed
|
||||||
|
let initial_batch = opts.block_sectors.unwrap_or(1);
|
||||||
|
let mut current_batch: u16 = initial_batch;
|
||||||
|
let mut consecutive_singles_ok: u32 = 0;
|
||||||
|
const ADAPTIVE_UPSCALE_THRESHOLD: u32 = 16;
|
||||||
let recovery = opts.full_recovery;
|
let recovery = opts.full_recovery;
|
||||||
|
|
||||||
let mut halted = false;
|
let mut halted = false;
|
||||||
@@ -2239,7 +2263,7 @@ impl Disc {
|
|||||||
const RANGE_BUDGET_CAP_SECS: u64 = 1800;
|
const RANGE_BUDGET_CAP_SECS: u64 = 1800;
|
||||||
const MAX_SKIPS_PER_RANGE: u32 = 10;
|
const MAX_SKIPS_PER_RANGE: u32 = 10;
|
||||||
let mut skip_count: u32;
|
let mut skip_count: u32;
|
||||||
let mut buf = vec![0u8; block_sectors as usize * 2048];
|
let mut buf = vec![0u8; initial_batch as usize * 2048];
|
||||||
|
|
||||||
// Pass 2 uses smaller sectors (1 vs 32) but same damage detection logic
|
// Pass 2 uses smaller sectors (1 vs 32) but same damage detection logic
|
||||||
const PASSN_DAMAGE_WINDOW: usize = 16;
|
const PASSN_DAMAGE_WINDOW: usize = 16;
|
||||||
@@ -2322,7 +2346,7 @@ impl Disc {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_start",
|
phase = "patch_start",
|
||||||
block_sectors,
|
block_sectors = initial_batch,
|
||||||
recovery,
|
recovery,
|
||||||
reverse = opts.reverse,
|
reverse = opts.reverse,
|
||||||
wedged_threshold = opts.wedged_threshold,
|
wedged_threshold = opts.wedged_threshold,
|
||||||
@@ -2463,13 +2487,13 @@ impl Disc {
|
|||||||
if block_end <= *range_pos {
|
if block_end <= *range_pos {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let span = (block_end - *range_pos).min(block_sectors as u64 * 2048);
|
let span = (block_end - *range_pos).min(current_batch as u64 * 2048);
|
||||||
(block_end - span, span)
|
(block_end - span, span)
|
||||||
} else {
|
} else {
|
||||||
if block_end >= end {
|
if block_end >= end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let span = (end - block_end).min(block_sectors as u64 * 2048);
|
let span = (end - block_end).min(current_batch as u64 * 2048);
|
||||||
(block_end, span)
|
(block_end, span)
|
||||||
};
|
};
|
||||||
let lba = (pos / 2048) as u32;
|
let lba = (pos / 2048) as u32;
|
||||||
@@ -2532,6 +2556,28 @@ impl Disc {
|
|||||||
if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD {
|
if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD {
|
||||||
consecutive_skips_without_recovery = 0;
|
consecutive_skips_without_recovery = 0;
|
||||||
}
|
}
|
||||||
|
// Adaptive batching: track clean single-sector reads to
|
||||||
|
// decide when to climb back to `initial_batch`. A batch
|
||||||
|
// read succeeding (count > 1) tells us the drive is healthy
|
||||||
|
// but doesn't accumulate toward upscale — we got back to
|
||||||
|
// batch=1 because of a failure here, we need consistent
|
||||||
|
// health at the slow tempo before scaling up again.
|
||||||
|
if count == 1 && current_batch < initial_batch {
|
||||||
|
consecutive_singles_ok += 1;
|
||||||
|
if consecutive_singles_ok >= ADAPTIVE_UPSCALE_THRESHOLD {
|
||||||
|
tracing::info!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "patch_adaptive_upscale",
|
||||||
|
from = current_batch,
|
||||||
|
to = initial_batch,
|
||||||
|
consecutive_singles_ok,
|
||||||
|
lba,
|
||||||
|
"adaptive batching: drive stable, climbing back to initial_batch"
|
||||||
|
);
|
||||||
|
current_batch = initial_batch;
|
||||||
|
consecutive_singles_ok = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
damage_window.push(true);
|
damage_window.push(true);
|
||||||
if damage_window.len() > PASSN_DAMAGE_WINDOW {
|
if damage_window.len() > PASSN_DAMAGE_WINDOW {
|
||||||
damage_window.remove(0);
|
damage_window.remove(0);
|
||||||
@@ -2629,7 +2675,14 @@ impl Disc {
|
|||||||
let mut bt_pos = backtrack_start;
|
let mut bt_pos = backtrack_start;
|
||||||
while bt_pos < backtrack_end {
|
while bt_pos < backtrack_end {
|
||||||
let span =
|
let span =
|
||||||
(backtrack_end - bt_pos).min(block_sectors as u64 * 2048);
|
// Backtrack always at count=1: this path
|
||||||
|
// fills a gap that the main loop's damage-
|
||||||
|
// window skip jumped over. Using batched
|
||||||
|
// reads here would lump good sectors into
|
||||||
|
// NonTrimmed marks when the gap contains
|
||||||
|
// even one bad sector. Backtrack is rare
|
||||||
|
// enough that the per-sector cost is fine.
|
||||||
|
(backtrack_end - bt_pos).min(2048);
|
||||||
let bt_lba = (bt_pos / 2048) as u32;
|
let bt_lba = (bt_pos / 2048) as u32;
|
||||||
let bt_count = (span / 2048) as u16;
|
let bt_count = (span / 2048) as u16;
|
||||||
let bt_bytes = bt_count as usize * 2048;
|
let bt_bytes = bt_count as usize * 2048;
|
||||||
@@ -2685,9 +2738,37 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
// Adaptive batching split decision: a batch-read
|
||||||
|
// failure (count > 1) is NOT a recorded failure.
|
||||||
|
// We don't yet know which sector in the batch was
|
||||||
|
// actually bad — could be one, could be many.
|
||||||
|
// Drop to count=1 and retry the SAME starting
|
||||||
|
// position so every sector gets individually
|
||||||
|
// probed. Cursor stays put; loop continues.
|
||||||
|
// Invariants: no good sector ever gets lumped
|
||||||
|
// into a NonTrimmed mark, no spurious
|
||||||
|
// consecutive_failures (which drives wedge
|
||||||
|
// detection), no damage_window pollution from
|
||||||
|
// batch-level signals.
|
||||||
|
if count > 1 {
|
||||||
|
tracing::info!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "patch_adaptive_split",
|
||||||
|
lba,
|
||||||
|
count,
|
||||||
|
from_batch = current_batch,
|
||||||
|
err_code = err.code(),
|
||||||
|
"adaptive batching: batch read failed, dropping to count=1 to probe individually"
|
||||||
|
);
|
||||||
|
current_batch = 1;
|
||||||
|
consecutive_singles_ok = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
blocks_read_failed += 1;
|
blocks_read_failed += 1;
|
||||||
consecutive_failures += 1;
|
consecutive_failures += 1;
|
||||||
consecutive_good_since_skip = 0;
|
consecutive_good_since_skip = 0;
|
||||||
|
consecutive_singles_ok = 0;
|
||||||
unreadable_count += 1;
|
unreadable_count += 1;
|
||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -3111,7 +3192,7 @@ impl Disc {
|
|||||||
|
|
||||||
if let Some(reporter) = opts.progress {
|
if let Some(reporter) = opts.progress {
|
||||||
let (s, bad_ranges_now) = read_shared(&shared);
|
let (s, bad_ranges_now) = read_shared(&shared);
|
||||||
let kind = if block_sectors == 1 {
|
let kind = if initial_batch == 1 {
|
||||||
crate::progress::PassKind::Scrape {
|
crate::progress::PassKind::Scrape {
|
||||||
reverse: opts.reverse,
|
reverse: opts.reverse,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,14 @@ impl SectorReader for PatternedSectorReader {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
// Fill each sector with ITS OWN LBA byte, not the starting LBA's
|
||||||
chunk.fill((lba & 0xff) as u8);
|
// byte. This matches real drive behavior: a multi-sector READ
|
||||||
|
// returns per-sector-correct data. Pre-0.18.13 only single-sector
|
||||||
|
// reads were exercised by patch tests, so the cheaper "fill the
|
||||||
|
// whole batch with one byte" worked; adaptive batching needs the
|
||||||
|
// per-sector pattern to verify correct positioning.
|
||||||
|
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
|
||||||
|
chunk.fill(((lba + i as u32) & 0xff) as u8);
|
||||||
}
|
}
|
||||||
Ok(buf.len())
|
Ok(buf.len())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user