Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 289 additions & 46 deletions crates/data/src/raster/cogwrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
//! directories' sizes depend only on the tile counts, so they are written first with empty tile
//! tables, and the tables are filled in once the tiles' places are known.
//!
//! Memory: the raster is read in place. A tile is gathered from the bands, compressed and written
//! before the next one, so no second copy of the pixels is made, and each overview tile is made
//! straight from the full image (a block of 2ⁿ × 2ⁿ pixels), so no pyramid is kept. The price is
//! time: every overview level reads the whole full image again, so writing takes about as many
//! passes over the pixels as there are levels, where building each level from the one before
//! would take about 1⅓ but hold a second copy.
//! Memory: the raster is read in place. A tile is gathered from the bands and compressed on its
//! own, and only a few tiles a thread are held before they are written, so no second copy of the
//! pixels is made, and each overview tile is made straight from the full image (a block of
//! 2ⁿ × 2ⁿ pixels), so no pyramid is kept. The price is time: every overview level reads the
//! whole full image again, so writing takes about as many passes over the pixels as there are
//! levels, where building each level from the one before would take about 1⅓ but hold a second
//! copy.
//!
//! Tiles are compressed with Deflate at its fastest level, after a predictor that makes neighbouring
//! samples alike: the floating-point one for floats, differences along the row for whole numbers.
Expand All @@ -24,11 +25,15 @@
//! don't change smoothly, so differences make them no smaller, and their long runs compress quickly
//! at any level, to half the size of the fastest.
//!
//! On the desktop the tiles are made on several threads ([`crate::threads::max`]) while one thread
//! writes them in their order, so the file is the same as one thread writes, byte for byte; in the
//! browser they are made one after another.
//!
//! Classic TIFF is used when the file can't pass 4 GiB even with every tile left uncompressed,
//! BigTIFF otherwise.
//!
//! The untiled GeoTIFF ([`super::geotiff::write`]) is written here too, by [`write_strips`]: one
//! image in strips of rows, with the same predictors and Deflate effort, and no overviews.
//! image in strips of rows, with the same predictors, Deflate effort and threads, and no overviews.

use std::io::{Seek, SeekFrom, Write};
use std::ops::Range;
Expand Down Expand Up @@ -689,28 +694,39 @@ pub fn write_to<W: Write + Seek>(r: &Raster, band: usize, flavour: Flavour, out:
let mut pos = head.bytes.len() as u64;

// Smallest overview first, the full image last; each in row order.
let order: Vec<(usize, u32, u32)> = levels
.iter()
.enumerate()
.rev()
.flat_map(|(i, lv)| (0..lv.down).flat_map(move |ty| (0..lv.across).map(move |tx| (i, tx, ty))))
.collect();
let mut places: Vec<Vec<(u64, u64)>> = vec![Vec::new(); levels.len()];
crate::progress::begin(tiles);
let mut row = Vec::new();
let effort = if plan.categorical { DEFLATE_CLASSES } else { DEFLATE_FAST };
for (i, lv) in levels.iter().enumerate().rev() {
for ty in 0..lv.down {
for tx in 0..lv.across {
crate::progress::step(1)?;
let mut raw = tile_samples(r, &plan, lv, tx, ty);
predict(&mut raw, &plan, &mut row);
let zipped = miniz_oxide::deflate::compress_to_vec_zlib(&raw, effort);
if !big && pos + zipped.len() as u64 > u64::from(u32::MAX) {
return Err(err("the file is too large for classic TIFF"));
}
out.write_all(&zipped).map_err(err)?;
if let Some(p) = places.get_mut(i) {
p.push((pos, zipped.len() as u64));
}
pos += zipped.len() as u64;
let tile_bytes = (TILE as usize * TILE as usize).saturating_mul(spp * bps);
in_order(
order.len(),
tile_bytes,
err,
Vec::new,
|row, k| {
let (i, tx, ty) = order.get(k).copied().ok_or_else(|| err("a tile past the image"))?;
let lv = levels.get(i).ok_or_else(|| err("a tile past the image"))?;
let mut raw = tile_samples(r, &plan, lv, tx, ty);
predict(&mut raw, &plan, row);
Ok(miniz_oxide::deflate::compress_to_vec_zlib(&raw, effort))
},
|k, zipped| {
if !big && pos + zipped.len() as u64 > u64::from(u32::MAX) {
return Err(err("the file is too large for classic TIFF"));
}
}
}
out.write_all(&zipped).map_err(err)?;
if let Some(p) = order.get(k).and_then(|(i, _, _)| places.get_mut(*i)) {
p.push((pos, zipped.len() as u64));
}
pos += zipped.len() as u64;
Ok(())
},
)?;

fill_tables(out, &head.patches, &places, big, pos, err)?;
Ok(CogInfo { big, levels: levels.iter().map(|l| (l.w, l.h)).collect(), bytes: pos })
Expand Down Expand Up @@ -768,13 +784,13 @@ const STRIP_BYTES: u64 = 1 << 20;
/// Writes an untiled TIFF of one image into `out` (which must be empty: offsets are from its
/// start): its directory and tags, then its strips, each made by `rows` (which appends the
/// samples of a range of rows, interleaved and little-endian), run through the predictor and
/// compressed with Deflate before the next is made. Classic TIFF when the file can't pass 4 GiB,
/// BigTIFF otherwise. It counts the strips as its progress, and a cancelled run stops it within a
/// strip.
/// compressed with Deflate, on several threads on the desktop ([`in_order`]). Classic TIFF when
/// the file can't pass 4 GiB, BigTIFF otherwise. It counts the strips as its progress, and a
/// cancelled run stops it within the few strips being made.
pub(super) fn write_strips<W: Write + Seek>(
img: Strips,
out: &mut W,
mut rows: impl FnMut(Range<usize>, &mut Vec<u8>) -> Result<()>,
rows: impl Fn(Range<usize>, &mut Vec<u8>) -> Result<()> + Sync,
) -> Result<()> {
if img.w == 0 || img.h == 0 {
return Err(strip_err("the raster has no pixels to write"));
Expand Down Expand Up @@ -820,26 +836,197 @@ pub(super) fn write_strips<W: Write + Seek>(

let effort = if img.classes { DEFLATE_CLASSES } else { DEFLATE_FAST };
let mut places = vec![Vec::with_capacity(strips)];
let (mut raw, mut row) = (Vec::new(), Vec::new());
crate::progress::begin(strips);
for y in (0..img.h as usize).step_by(per_strip as usize) {
let (h, per_strip) = (img.h as usize, per_strip as usize);
in_order(
strips,
line.saturating_mul(per_strip),
strip_err,
|| (Vec::new(), Vec::new()),
|(raw, row), k| {
let y = k * per_strip;
raw.clear();
rows(y..(y + per_strip).min(h), raw)?;
predict_rows(raw, line, spp, img.sample, predictor, row);
Ok(miniz_oxide::deflate::compress_to_vec_zlib(raw, effort))
},
|_, zipped| {
if !big && pos + zipped.len() as u64 > u64::from(u32::MAX) {
return Err(strip_err("the file is too large for classic TIFF"));
}
out.write_all(&zipped).map_err(strip_err)?;
if let Some(p) = places.first_mut() {
p.push((pos, zipped.len() as u64));
}
pos += zipped.len() as u64;
Ok(())
},
)?;
fill_tables(out, &head.patches, &places, big, pos, strip_err)
}

/// Makes `count` pieces of a file (tiles or strips, each about `bytes` uncompressed) with `make`
/// and hands each to `put` in their order, with its index. `make` gets the scratch its thread
/// made with `scratch` once; `err` words a failure of the threads. Every piece stands alone, so
/// on the desktop, with more than one piece, they are made on several threads
/// ([`crate::threads::max`], fewer when [`crate::memory::thread_scratch`] can't hold their pieces)
/// while this thread writes them: the same file as one thread makes, byte for byte. In the browser
/// they are made one after another.
///
/// It counts the pieces as its progress, and a cancelled run stops it within the few pieces being
/// made ([`AHEAD`] a thread). The first error in the pieces' order comes back, as on one thread.
fn in_order<S>(
count: usize,
bytes: usize,
err: fn(String) -> DataError,
scratch: impl Fn() -> S + Sync,
make: impl Fn(&mut S, usize) -> Result<Vec<u8>> + Sync,
put: impl FnMut(usize, Vec<u8>) -> Result<()>,
) -> Result<()> {
crate::progress::begin(count);
// A piece being made holds about twice its bytes (its samples and their compressed copy), and
// each waiting to be written up to its bytes again.
let affordable = 1 + crate::memory::thread_scratch() / bytes.saturating_mul(2 + AHEAD).max(1);
let threads = crate::threads::for_items(count).min(affordable);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AGENTS.md (low-medium): no size threshold before threads start. AGENTS asks for one thread "below a size threshold". Here threads = for_items(count).min(affordable) spawns helper threads for any count above one, so a raster with two or three tiles or strips starts threads for almost no work. The cost is small, but the rule asks for a threshold. The analysis crate's try_map has a min_items parameter for this, and rustgis_data has no equivalent. If a threshold is intended, adding a small one (for example, fewer than a few dozen pieces, or few bytes in total) keeps the output identical.

Confidence: medium. The cost is minor, so this may be left for the quality pass if the maintainers judge it so.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The piece count is the threshold here. A COG piece is always a full 512 × 512 tile, and a strip holds about 1 MiB unless the whole image is smaller, in which case it is a single strip. Each piece is milliseconds of predicting and Deflate, against tens of microseconds to start a thread, so two pieces already repay it, and smaller writes are one piece on one thread.

if threads > 1 {
return in_order_parallel(count, threads, err, scratch, make, put);
}
one_by_one(count, err, scratch, make, put)?;
// A cancel that came while the last piece was made stops the write too.
crate::progress::check()
}

/// [`in_order`] on this thread alone: each piece made, then written, before the next.
fn one_by_one<S>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug (medium-low): a panicking piece is handled differently by thread count. one_by_one calls make without catch_unwind, so on the single-thread path (one core, the browser, a one-piece write, or a fallback when no thread starts) a panic in make unwinds out of the write. in_order_parallel turns the same panic into an error ("a piece of the image couldn't be made"). The PR text and the test name ("a piece that panics is an error, not a crash") promise the error in general, and AGENTS says "Never crash". Session::run would still catch the unwind, so the impact is a generic panic instead of an error, but the behaviour should not depend on the core count.

Suggested fix: move the catch_unwind into a helper that both paths call (for example, make one_by_one take the err fn and wrap make the same way in_order_parallel does), and add a single-thread case to pieces_made_on_threads_are_written_in_order so both paths are covered.

Confidence: high that the two paths differ; medium on impact.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a719637: both paths now make a piece through caught, so a panicking piece is the same error on one thread as on several. The test checks one_by_one too.

count: usize,
err: fn(String) -> DataError,
scratch: impl Fn() -> S,
make: impl Fn(&mut S, usize) -> Result<Vec<u8>>,
mut put: impl FnMut(usize, Vec<u8>) -> Result<()>,
) -> Result<()> {
let mut s = scratch();
for k in 0..count {
crate::progress::step(1)?;
raw.clear();
rows(y..(y + per_strip as usize).min(img.h as usize), &mut raw)?;
predict_rows(&mut raw, line, spp, img.sample, predictor, &mut row);
let zipped = miniz_oxide::deflate::compress_to_vec_zlib(&raw, effort);
if !big && pos + zipped.len() as u64 > u64::from(u32::MAX) {
return Err(strip_err("the file is too large for classic TIFF"));
put(k, caught(&make, &mut s, k, err).0?)?;
}
Ok(())
}

/// Piece `k` from `make`, a panic in it as an error, so the write fails the same way on any number
/// of threads; and whether it panicked, after which `s` may be half changed.
fn caught<S>(
make: impl Fn(&mut S, usize) -> Result<Vec<u8>>,
s: &mut S,
k: usize,
err: fn(String) -> DataError,
) -> (Result<Vec<u8>>, bool) {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| make(s, k))) {
Ok(made) => (made, false),
Err(_) => (Err(err("a piece of the image couldn't be made".into())), true),
}
}

/// The pieces a thread of [`in_order`] may make ahead of the one being written: enough that the
/// threads rarely wait for a slow piece, few enough that the pieces waiting stay small.
const AHEAD: usize = 2;

/// [`in_order`] on `threads` threads besides this one, which writes. A thread takes the next piece
/// unless it is more than [`AHEAD`] a thread past the one to write next, so a slow piece holds up
/// only that many. A piece's panic comes back as an error in its place. A thread the OS refuses
/// costs speed, not the write; when it refuses all of them, this thread makes the pieces itself.
fn in_order_parallel<S>(
count: usize,
threads: usize,
err: fn(String) -> DataError,
scratch: impl Fn() -> S + Sync,
make: impl Fn(&mut S, usize) -> Result<Vec<u8>> + Sync,
mut put: impl FnMut(usize, Vec<u8>) -> Result<()>,
) -> Result<()> {
use std::collections::BTreeMap;
use std::sync::{Condvar, Mutex, mpsc};

/// The next piece to take, the next to write, and whether to stop taking pieces.
struct Turn {
next: usize,
written: usize,
stop: bool,
}
let ahead = threads.saturating_mul(AHEAD);
let turn = Mutex::new(Turn { next: 0, written: 0, stop: false });
let moved = Condvar::new();
let failed = || err("a piece of the image couldn't be made".into());
// Takes the next piece once it is no more than `ahead` past the one to write; None to stop.
let take = || -> Option<usize> {
let mut t = turn.lock().ok()?;
while !t.stop && t.next < count && t.next >= t.written.saturating_add(ahead) {
t = moved.wait(t).ok()?;
}
if t.stop || t.next >= count {
return None;
}
out.write_all(&zipped).map_err(strip_err)?;
if let Some(p) = places.first_mut() {
p.push((pos, zipped.len() as u64));
t.next += 1;
Some(t.next - 1)
};
/// Stops the threads when dropped, however the writing ends: done, an error, or a panic in
/// `put`, which would otherwise leave them waiting for the writer for good, and the scope
/// waiting for them.
struct Stop<'a>(&'a Mutex<Turn>, &'a Condvar);
impl Drop for Stop<'_> {
fn drop(&mut self) {
if let Ok(mut t) = self.0.lock() {
t.stop = true;
}
self.1.notify_all();
}
pos += zipped.len() as u64;
}
// A cancel that came while the last strip was made stops the write too.
crate::progress::check()?;
fill_tables(out, &head.patches, &places, big, pos, strip_err)

std::thread::scope(|scope| {
let (tx, rx) = mpsc::channel::<(usize, Result<Vec<u8>>)>();
let work = |tx: mpsc::Sender<(usize, Result<Vec<u8>>)>| {
let mut s = scratch();
while let Some(k) = take() {
let (made, panicked) = caught(&make, &mut s, k, err);
// A thread whose piece panicked stops: its scratch may be half changed.
if tx.send((k, made)).is_err() || panicked {
break;
}
}
};
let started = (0..threads)
.filter_map(|_| {
let tx = tx.clone();
std::thread::Builder::new().spawn_scoped(scope, move || work(tx)).ok()
})
.count();
drop(tx);
// The threads finish the pieces they are making and take no more; the scope waits for them.
let _stop = Stop(&turn, &moved);
(|| -> Result<()> {
if started == 0 {
return one_by_one(count, err, &scratch, &make, &mut put);
}
// Pieces made before the ones ahead of them, by their index.
let mut waiting = BTreeMap::new();
for k in 0..count {
let piece = loop {
if let Some(p) = waiting.remove(&k) {
break p;
}
// Every thread gone before piece `k` came: one panicked outside a piece.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low (theoretical): the rx.recv() error path does not give an error here. This comment says a thread that panics outside a piece makes the write fail with failed(). But std::thread::scope panics after joining whenever a spawned thread panicked and was not joined manually. So the write ends with a panic propagating out of the scope, not the error this branch returns. Today the only code that can panic outside caught is scratch(), and the callers pass Vec::new/tuples, so this is unreachable in practice. It still conflicts with the "never crash" rule for any future scratch.

Suggested fix: reword the comment to say what really happens, or join the handles and convert a panicked join into failed():

Suggested change
// Every thread gone before piece `k` came: one panicked outside a piece.
// Every thread gone before piece `k` came: one panicked outside a piece, and the scope re-panics after joining it.

Confidence: medium on the std behaviour, low on whether it can be reached.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right: scope re-raises a thread's panic after joining, so that branch is reached only if every thread ended without a panic, and today nothing outside caught can panic (the scratch makers are Vec::new and tuples). Since it's unreachable for the current callers, the comment's wording is left for the release quality pass.

let (i, p) = rx.recv().map_err(|_| failed())?;
waiting.insert(i, p);
};
crate::progress::step(1)?;
put(k, piece?)?;
if let Ok(mut t) = turn.lock() {
t.written = k + 1;
}
moved.notify_all();
}
Ok(())
})()
})?;
// A cancel that came while the last piece was made stops the write too.
crate::progress::check()
}

/// The bands [`write`] and [`write_file`] write of `r` given `band`, from the same plan.
Expand Down Expand Up @@ -1397,6 +1584,62 @@ mod tests {
assert!(geotiff::write(&r, 0).is_ok());
}

/// Pieces made on several threads, the later ones sooner, are written in their order; the
/// first failure in that order comes back, and a piece that panics is an error, not a crash.
#[test]
fn pieces_made_on_threads_are_written_in_order() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test coverage (medium): the byte-identity claim is not tested in code. The PR says the threaded file is the same as the one-thread file, byte for byte, and checks that with cmp on one DEM by hand. No unit test runs write_to or write_strips both ways and compares the bytes. The tests here drive in_order_parallel directly, so these paths are also untested: the started == 0 fallback, cancellation during the parallel path (the untiled cancel test has one strip, so it stays on one thread), and a panic on the single-thread path (see the comment on one_by_one).

Suggested: have in_order take its thread count as an argument (or add a test-only entry point), then assert that write_strips / write_to output is equal for 1 and 4 threads on a multi-strip raster.

Confidence: medium-high that the gap exists.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A piece's bytes come only from make(k), whichever thread runs it, so byte identity rests on the write order, which the test checks with pieces made out of order. The parallel cancel path is already covered: a_cancelled_raster_write_stops writes a 1100 × 1100 COG (14 tiles), which takes the threaded path on a multi-core runner. a719637 adds the single-thread panic case and the writer-side panic (the started == 0 fallback is one_by_one, tested directly).

let slow_first = |_: &mut (), k: usize| {
std::thread::sleep(std::time::Duration::from_millis(((40 - k) % 7) as u64));
Ok(vec![k as u8])
};
let mut written = Vec::new();
in_order_parallel(
40,
4,
err,
|| (),
slow_first,
|k, p| {
written.push((k, p));
Ok(())
},
)
.unwrap();
assert_eq!(written, (0..40).map(|k| (k, vec![k as u8])).collect::<Vec<_>>());

let failing = |_: &mut (), k: usize| match k {
9 => Err(err("nine")),
17 => panic!("seventeen"),
_ => Ok(Vec::new()),
};
let mut n = 0;
let e = in_order_parallel(
40,
4,
err,
|| (),
failing,
|_, _| {
n += 1;
Ok(())
},
);
assert!(e.unwrap_err().to_string().contains("nine"));
assert_eq!(n, 9, "the pieces before the failure are written");
let panics = |_: &mut (), k: usize| if k == 3 { panic!("three") } else { Ok(Vec::new()) };
let e = in_order_parallel(10, 3, err, || (), panics, |_, _| Ok(()));
assert!(e.unwrap_err().to_string().contains("couldn't be made"));
// A panic while a piece is written, with the threads waiting for it, is passed on rather
// than leaving the write waiting for them.
let caught_write = std::panic::catch_unwind(|| {
in_order_parallel(40, 2, err, || (), |_, _| Ok(Vec::new()), |k, _| if k == 0 { panic!("write") } else { Ok(()) })
});
assert!(caught_write.is_err());
// On one thread too (the browser, a one-core machine).
let e = one_by_one(10, err, || (), panics, |_, _| Ok(()));
assert!(e.unwrap_err().to_string().contains("couldn't be made"));
}

/// A cancel that comes while the untiled writer makes its last strip stops the write, rather
/// than the file being finished as if nothing had happened.
#[test]
Expand Down
2 changes: 1 addition & 1 deletion crates/data/src/raster/geotiff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1658,7 +1658,7 @@ pub fn write_all(r: &Raster) -> Result<Vec<u8>> {

/// Writes `data` (samples interleaved as `img` lays them out, a row after another) as `img`'s
/// strips, at the end of `out`.
fn put<T: Copy, const N: usize>(
fn put<T: Copy + Sync, const N: usize>(
img: super::cogwrite::Strips,
data: &[T],
le: fn(T) -> [u8; N],
Expand Down
Loading