diff --git a/lab/leavegen.txt b/lab/leavegen.txt index e73e949..367238f 100644 --- a/lab/leavegen.txt +++ b/lab/leavegen.txt @@ -22,17 +22,40 @@ TL;DR -- IF YOU JUST WANT A GOOD TABLE Run this. The census works out every leave's value exactly, one board at a time, and needs no environment variables: - cargo run --release --bin leave -- english-census CSW24.kwg - - 4x256 - -That builds a competitive table with no tuning: 4 generations of 256 boards, -which is enough for the leaves to settle, iterated from the null starting -tables (the two "-"). Every default is already the one to ship. The 256-board -count is measured on this English word list; for a different tile set, let the -adaptive stop described below size the run to the leaves themselves. The census -builds faster than plain self-play or board sampling and plays at least as -well, which is why it is the one to reach for. Those alternatives, and every -lever that turned out not to help, are described below; the best current way to -run each method is collected at the end of this file. + cargo run --release --bin leave -- english-census CSW24.kwg - - 3x256,2048 + +That builds a competitive table with no tuning: three cheap generations of 256 +boards to settle the leaves, then one generation of 2048 boards to pin them +down (3x256 is shorthand for three of them), iterated from the null starting +tables (the two "-"). Every default is already the one to ship. Those counts +are measured on this English word list, not universal constants; a different +tile set wants its own sweep, and the adaptive stop described below is not the +shortcut around it that it looks like. The census builds faster than plain +self-play or board sampling and plays at least as well, which is why it is the +one to reach for. Those alternatives, and every lever that turned out not to +help, are described below; the best current way to run each method is collected +at the end of this file. + +The last generation's board count is the number that matters, and it is the +only reason this costs more than the older recommendation of 4x256 (four +generations of 256). Both are worth knowing. 4x256 finishes in about 44 seconds +to this one's 142, because every board is fixed by its position in the run, so +4x256 builds 256 boards and reuses them while 3x256,2048 must build all 2048 +for its last generation. What the extra wait buys is about a hundredth of a +percentage point: 4096 boards beat 256 by roughly 50.02 to 49.98 over five +million game pairs, and 3x256,2048 edges 4x256 by a similar hair on every seed +tried. Small -- but it has landed the same way every time it has been measured, +so 4x256 is not the best table, just a very close and much faster one. Reach +for 4x256 while iterating, and for 3x256,2048 when the table is one you mean to +keep. Fewer than 2048 boards in the final generation gives the edge back. + +The 4x256 is shorthand for four generations of 256 boards. You can also write +the counts out, one per generation, to let them differ -- 256,512,1024 runs +three generations at those counts. Because every board is fixed by its position +in the run, the larger generations reuse the boards the smaller ones already +built: 256,512,1024 does the board work of a single 1024-board run, not the sum +of the three. So a cheap early generation can settle the table before a costly +last one pins it down, at no extra board cost. GENERATING A TABLE BY SELF-PLAY @@ -1769,14 +1792,46 @@ good table, use the census. Census -- exact, and the one to use. It values every leave on a board exactly, then averages over many boards. + cargo run --release --bin leave -- english-census CSW24.kwg - - 3x256,2048 + +Three cheap generations of 256 boards settle the leaves, then one generation of +2048 pins them down. There is a cheaper recipe worth knowing, and which you +want depends on what the table is for: + cargo run --release --bin leave -- english-census CSW24.kwg - - 4x256 -4 generations of 256 boards settles the leaves: adding more boards or -generations no longer moves the table. That 256 is measured on this English -word list, not a universal constant; for a different tile set, let the run -size itself with the adaptive stop (WOLGES_CENSUS_CI_STOP_FRAC, in the -confidence-interval section earlier in this file), which ends a run once the -leaves have settled to a target width instead of at a preset count. +4x256 takes about 44 seconds against roughly 142 for 3x256,2048, because every +board is fixed by its position in the run: 4x256 builds 256 boards and reuses +them for its later generations, while 3x256,2048 must build all 2048 for its +last one. Three times the wait buys about a hundredth of a percentage point -- +4096 boards beat 256 by about 50.02 to 49.98 over five million game pairs, and +3x256,2048 edges 4x256 by a similar hair on every seed tried. That is a small +edge, but it has fallen the same way every time it has been measured, so 4x256 +is not quite the best table, only a very close and much faster one. Take 4x256 +while iterating; take 3x256,2048 for a table you intend to keep. + +Below 2048 boards in the final generation the edge goes away, and more +generations do not pay -- a fifth measured no better than the third. Those +counts are measured on this English word list, not universal constants, so a +different tile set wants its own sweep rather than these numbers taken on +faith. + +The adaptive stop (WOLGES_CENSUS_CI_STOP_FRAC, in the confidence-interval +section earlier in this file) looks like the way to avoid that sweep: it ends a +run once the leaves have settled to a target width rather than at a preset +count. Do not reach for it expecting a free lunch. Asked to stop once nine +leaves in ten had settled, it stopped after 576 boards and produced a table +that lost to 3x256,2048 by about 0.14 of a percentage point -- a real loss, not +noise. Settling by that measure is evidently not the same as playing as well, +so a stop wants a far stricter fraction than nine in ten, and wants checking +against a fixed-count table before being trusted. Note also that the stop +requires WOLGES_CENSUS_RACK_SUMMARY=1, which makes the census write a rack +summary to decompose rather than a table directly, and that it runs as a single +generation -- so it reads from an already-settled table rather than the null +one, since one generation never iterates the leaves. That measurement changed +two things at once (the stop and the rack summary), so which of them cost the +0.14 is not yet pinned down. + WOLGES_POOL_* shift the board window; for dynamic leaves add WOLGES_FULL=1 to keep the full-length values. diff --git a/src/main_leave.rs b/src/main_leave.rs index ab95687..b2c706c 100644 --- a/src/main_leave.rs +++ b/src/main_leave.rs @@ -4878,6 +4878,35 @@ fn wolges_census_scatter() -> error::Returns { // generations (Mutex so the first gen's writer publishes to later gens' readers). type SheetCacheSlot = std::sync::Mutex, Vec)>>; +// Plan the sheet cache for a board-count spec: which slots each generation is worth +// caching, and how many slots the cache needs at all. Returns (live_after, cache_len). +// +// live_after[g] = the largest board count of ALL the generations after g -- a suffix +// maximum, NOT simply the next generation's count. A slot stays worth keeping while ANY +// later generation still has a board for it, so the lookahead cannot stop at the next +// one: in 400,100,300 the 300-board generation still reads slots 0..300, so the +// 400-board generation must hold 300 of them ACROSS the 100-board dip. Comparing only +// against the next count would keep 100 and rebuild 200 slots later. +// +// cache_len = the highest slot any generation actually caches. A generation caches a +// slot only if it both has a board for it (board_counts[g]) and a later generation will +// read it (live_after[g]), so the cache never needs more than the largest of those +// minimums -- 200,1000,400,300 needs 400 slots rather than the largest generation's +// 1000, and 256,256,256,2048 needs 256 rather than 2048, because the last generation's +// sheets are never kept. +fn census_sheet_reuse_plan(board_counts: &[u64]) -> (Vec, usize) { + let gens = board_counts.len(); + let mut live_after = vec![0usize; gens]; + for g in (0..gens.saturating_sub(1)).rev() { + live_after[g] = (board_counts[g + 1] as usize).max(live_after[g + 1]); + } + let cache_len = (0..gens) + .map(|g| (board_counts[g] as usize).min(live_after[g])) + .max() + .unwrap_or(0); + (live_after, cache_len) +} + // Serialize the valued leaves to a klv2 file -- the same DawgOnly/Wolges build as // `buildlex -klv2` runs on the csv, but in-process. The machine word is the // leave's sorted tile bytes; the value is (value_mp(idx) - baseline_mp) points as f32. @@ -5064,8 +5093,9 @@ fn generate_census_leaves 1; // online mini-batch SGD (single-generation only). WOLGES_CENSUS_BATCH = boards @@ -5207,19 +5237,25 @@ fn generate_census_leaves-.klv2, so a crash loses no completed gens and // WOLGES_CENSUS_RESUME can continue. One in-process klv2 build per gen (cheap vs the @@ -5588,16 +5624,18 @@ fn generate_census_leaves = if sheet_reuse { - (0..max_boards) - .map(|_| std::sync::Mutex::new(None)) - .collect() - } else { - Vec::new() - }; + // per-board (sheet, unseen) cache for sheet-reuse: a slot is filled by the first gen + // that reaches it AND that a later gen will read back (live_after), then read by every + // later gen (a uniform spec fills every slot in gen 0; a growing one fills the new + // slots as its board count climbs; the last gen fills nothing), then freed at the gen + // boundary past which no gen reads it. The multi-gen barrier orders a gen's writes + // before any later gen's reads, and each slot is written once, by whichever thread + // pulls it. Sized to sheet_cache_len -- the highest slot any gen actually caches, which + // a spec whose last gen is its largest keeps far below that gen's board count. Empty + // unless sheet_reuse. + let sheet_cache: Vec = (0..sheet_cache_len) + .map(|_| std::sync::Mutex::new(None)) + .collect(); eprintln!("census: {num_threads} threads over {board_counts:?} boards/gen"); std::thread::scope(|s| { @@ -6152,6 +6190,10 @@ fn generate_census_leaves start_gen, not > 0). - let reuse_board = sheet_reuse && gen_idx > start_gen; + // sheet-reuse: each slot's board + sheet is cached the first gen + // that reaches it. A slot below the running max of the prior gens + // (prior_max_boards) is already cached, so skip the game replay and + // re-value from the cache; slots at or above it are new this gen and + // get built + cached. On resume the cache starts empty (prior_max 0), + // so the first resumed gen rebuilds. + let reuse_board = sheet_reuse && (b as usize) < prior_max_boards; if !reuse_board { // greedy-play fresh games (from this slot's own rng) until one // reaches this slot's target fill. The target ROUND-ROBINS across @@ -6427,7 +6471,14 @@ fn generate_census_leaves( mod tests { use super::*; + // Every spec below is (board_counts, expected live_after, expected cache_len). + const SHEET_PLANS: &[(&[u64], &[usize], usize)] = &[ + // the lookahead is a suffix maximum, not the next gen's count: the 300-board gen + // still reads slots 0..300, so the 400-board gen keeps 300 of them ACROSS the + // 100-board dip. A next-gen-only rule would keep min(400,100)=100 and rebuild. + (&[400, 100, 300], &[300, 300, 0], 300), + // a dip that is genuinely the end: nothing after the 100 needs more than 100. + (&[400, 300, 100], &[300, 100, 0], 300), + // uniform: gen 0 builds every slot, the rest reuse; the last gen keeps none. + (&[256, 256, 256, 256], &[256, 256, 256, 0], 256), + // growing: each gen adds the new slots on top, the last gen keeps none. + (&[256, 512, 1024], &[1024, 1024, 0], 512), + // the ship recipe: three cheap gens share 256 slots, the 2048 gen keeps nothing. + (&[256, 256, 256, 2048], &[2048, 2048, 2048, 0], 256), + (&[200, 1000, 400, 300], &[1000, 400, 300, 0], 400), + // up, down, up again, down: the 700 must survive the 400 dip for the 700 gen. + (&[200, 1000, 400, 700, 350], &[1000, 700, 700, 350, 0], 700), + // single gen: nothing follows, so nothing is ever cached. + (&[256], &[0], 0), + ]; + + #[test] + fn census_sheet_reuse_plan_looks_past_the_next_generation() { + for &(counts, want_live, want_len) in SHEET_PLANS { + let (live_after, cache_len) = census_sheet_reuse_plan(counts); + assert_eq!(live_after, want_live, "live_after for {counts:?}"); + assert_eq!(cache_len, want_len, "cache_len for {counts:?}"); + } + } + + // Walk each spec the way the run does -- reuse a slot below the prior gens' running + // max, cache one below live_after, free the rest at the boundary -- and check the two + // invariants the plan has to guarantee: a reused slot is always still cached (no + // silent rebuild, no missing sheet), and no cached slot ever sits at or past cache_len. + #[test] + fn census_sheet_reuse_plan_never_reads_an_uncached_slot() { + for &(counts, _, want_len) in SHEET_PLANS { + let (live_after, cache_len) = census_sheet_reuse_plan(counts); + let mut cached = std::collections::HashSet::::new(); + let mut prior_max = 0usize; + let mut high_water = 0usize; + for (g, &n) in counts.iter().enumerate() { + for b in 0..n as usize { + if b < prior_max { + assert!( + cached.contains(&b), + "{counts:?} gen {g} reuses slot {b} but it was never cached" + ); + } else if b < live_after[g] { + cached.insert(b); + high_water = high_water.max(b + 1); + } + } + cached.retain(|&b| b < live_after[g]); + prior_max = prior_max.max(n as usize); + } + assert!( + high_water <= cache_len, + "{counts:?} cached slot {high_water} past cache_len {cache_len}" + ); + // and the size is tight, not merely sufficient. + assert_eq!(high_water, want_len, "cache_len not tight for {counts:?}"); + assert!( + cached.is_empty(), + "{counts:?} kept sheets after the last gen" + ); + } + } + #[test] fn parse_board_counts_expands_repeats() { assert_eq!(parse_board_counts("256").unwrap(), vec![256]);