Skip to content

zu2: memory_pages bounds nothing while an async write is going, and the overshoot grows with the load #775

Description

@tamnd

Options::memory_pages says it is "pages of anonymous memory the log may hold". Under Durability::Async it is not a bound on anything. It is a target the flusher works towards afterwards, and while the writing is going the log holds whatever the writer has appended.

Measured with a probe that samples resident_pages on a thread while a single session writes 8 KiB values, memory_pages: 2, mutable_pages: 1, compact_below: 0 so nothing but eviction moves the head:

durability records written bound peak while writing
Async 10000 80 MiB 8 MiB 48 MiB, 12 pages
Async 40000 320 MiB 8 MiB 240 MiB, 60 pages
Durable 40000 320 MiB 8 MiB 12 MiB, 3 pages

Four times the load is five times the overshoot, so this is not a constant lag with a ceiling, it is the whole write staying in memory. At Durable the same load holds three pages, which is the bound plus the page being appended to and is exactly what the option promises.

The mechanism is not subtle once you look at it. Log::ensure_page allocates a page whenever an append reaches one, and it consults nothing:

let fresh = unsafe { alloc_zeroed(page_layout()) };
...
slot.store(fresh, Ordering::Release);
self.anonymous.fetch_add(1, Ordering::AcqRel);

The only thing that ever gives a page back is evict_behind, which runs from evict_settled, which is the flusher's. And evict_behind cannot take a page until it is durable:

if page_start(victim + 1) > self.flushed() {
    break;
}

which is right, a page that left memory before its bytes were on the device would be a reader preading a hole. So at Durable the writer is its own back pressure, because it does not return until the bytes are down, and eviction always has something to take. At Async nothing connects the writer to the flusher at all, and the log grows in memory at whatever rate the CPU can append.

The existing test knows this and works around it. the_page_bound_holds_after_the_writing_stops in crates/zu2/tests/end_to_end.rs is named for it and asserts after drop(s) and db.sync(), with a comment that what is being waited for is a thread noticing. Nothing asserts anything about during.

Why this matters beyond the doc being wrong. #767 is heading towards making a bound the default, since the anonymous total at defaults is the whole log and memory_pages is usize::MAX. A bound that a bulk load walks straight through would give a default that looks right in the options and does nothing for the load phase of every benchmark we run, which is the phase where the memory figure is worst.

The fix is the one the log already uses for its other ceiling. A writer that fills the log waits for a compaction pass, a_writer_that_fills_the_log_waits_for_a_pass in db.rs covers it. The same shape applies here: an append that would take the anonymous count past memory_pages wakes the flusher and waits for it, rather than allocating and moving on. That turns the option into what it says, at the cost of turning a write burst that outruns the device into a wait, which is what a bound means.

  • Make the append path respect the bound rather than the flusher chase it
  • A test that samples during the write and not after it, with the numbers above as the thing it catches
  • Say in Options::memory_pages which durability modes it holds under, until it holds under both
  • Re-measure, because a writer that waits for the flusher is a throughput change and it has to be on a host

Numbers above are from the laptop, and they are a behaviour observation rather than a throughput one: what is being claimed is that the peak scales with the load, which it does by a factor of five across a factor of four. Nothing published comes from this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingperfPerformance and resource budgets

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions