From 0533923f91c4a7ce2bb4fb74b82cdc881a193aa2 Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Tue, 9 Apr 2024 18:07:53 +0200 Subject: [PATCH] Reduce memory usage to represent buffers by up to 50% (#10321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This should help with some of the memory problems reported in https://github.com/zed-industries/zed/issues/8436, especially the ones related to large files (see: https://github.com/zed-industries/zed/issues/8436#issuecomment2037442695), by **reducing the memory required to represent a buffer in Zed by ~50%.** ### How? Zed's memory consumption is dominated by the in-memory representation of buffer contents. On the lowest level, the buffer is represented as a [Rope](https://en.wikipedia.org/wiki/Rope_(data_structure)) and that's where the most memory is used. The layers above — buffer, syntax map, fold map, display map, ... — basically use "no memory" compared to the Rope. Zed's `Rope` data structure is itself implemented as [a `SumTree` of `Chunks`](https://github.com/zed-industries/zed/blob/8205c52d2bc204b8234f9306562d9000b1691857/crates/rope/src/rope.rs#L35-L38). An important constant at play here is `CHUNK_BASE`: `CHUNK_BASE` is the maximum length of a single text `Chunk` in the `SumTree` underlying the `Rope`. In other words: It determines into how many pieces a given buffer is split up. By changing `CHUNK_BASE` we can adjust the level of granularity withwhich we index a given piece of text. Theoretical maximum is the length of the text, theoretical minimum is 1. Sweet spot is somewhere inbetween, where memory use and performance of write & read access are optimal. We started with `16` as the `CHUNK_BASE`, but that wasn't the result of extensive benchmarks, more the first reasonable number that came to mind. ### What This changes `CHUNK_BASE` from `16` to `64`. That reduces the memory usage, trading it in for slight reduction in performance in certain benchmarks. ### Benchmarks I added a benchmark suite for `Rope` to determine whether we'd regress in performance as `CHUNK_BASE` goes up. I went from `16` to `32` and then to `64`. While `32` increased performance and reduced memory usage, `64` had one slight drop in performance, increases in other benchmarks and substantial memory savings. | `CHUNK_BASE` from `16` to `32` | `CHUNK_BASE` from `16` to `64` | |-------------------|--------------------| | ![chunk_base_16_to_32](https://github.com/zed-industries/zed/assets/1185253/fcf1f9c6-4f43-4e44-8ef5-29c1e5d8e2b9) | ![chunk_base_16_to_64](https://github.com/zed-industries/zed/assets/1185253/d82a0478-eeef-43d0-9240-e0aa9df8d946) | ### Real World Results We tested this by loading a 138 MB `*.tex` file (parsed as plain text) into Zed and measuring in `Instruments.app` the allocation. #### standard allocator Before, with `CHUNK_BASE: 16`, the memory usage was ~827MB after loading the buffer. | `CHUNK_BASE: 16` | |---------------------| | ![memory_consumption_chunk_base_16_std_alloc](https://github.com/zed-industries/zed/assets/1185253/c1e04c34-7d1a-49fa-bb3c-6ad10aec6e26) | After, with `CHUNK_BASE: 64`, the memory usage was ~396MB after loading the buffer. | `CHUNK_BASE: 64` | |---------------------| | ![memory_consumption_chunk_base_64_std_alloc](https://github.com/zed-industries/zed/assets/1185253/c728e134-1846-467f-b20f-114a582c7b5a) | #### `mimalloc` `MiMalloc` by default and that seems to be pretty aggressive when it comes to growing memory. Whereas the std allocator would go up to ~800mb, MiMalloc would jump straight to 1024MB. I also can't get `MiMalloc` to work properly with `Instruments.app` (it always shows 15MB of memory usage) so I had to use these `Activity Monitor` screenshots: | `CHUNK_BASE: 16` | |---------------------| | ![memory_consumption_chunk_base_16_mimalloc](https://github.com/zed-industries/zed/assets/1185253/1e6e05e9-80c2-4ec7-9b0e-8a6fa78836eb) | | `CHUNK_BASE: 64` | |---------------------| | ![memory_consumption_chunk_base_64_mimalloc](https://github.com/zed-industries/zed/assets/1185253/8a47e982-a675-4db0-b690-d60f1ff9acc8) | ### Release Notes Release Notes: - Reduced memory usage for files by up to 50%. --------- Co-authored-by: Antonio --- Cargo.lock | 136 ++++++++++++++++++++++++ crates/rope/Cargo.toml | 5 + crates/rope/benches/rope_benchmark.rs | 144 ++++++++++++++++++++++++++ crates/rope/src/rope.rs | 2 +- 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 crates/rope/benches/rope_benchmark.rs diff --git a/Cargo.lock b/Cargo.lock index c9cb4099e5..e3617b8339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.5.0" @@ -1806,6 +1812,12 @@ dependencies = [ "winx", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.1.2" @@ -1914,6 +1926,33 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cca491388666e04d7248af3f60f0c40cfb0991c72205595d7c396e3510207d1a" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.3.0" @@ -2783,6 +2822,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c76e09c1aae2bc52b3d2f29e13c6572553b30c4aa1b8a49fd70de6412654cb" +dependencies = [ + "anes", + "atty", + "cast", + "ciborium", + "clap 3.2.25", + "criterion-plot", + "itertools 0.10.5", + "lazy_static", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-channel" version = "0.5.8" @@ -2836,6 +2911,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-bigint" version = "0.4.9" @@ -4478,6 +4559,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -6399,6 +6490,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "oorandom" +version = "11.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" + [[package]] name = "opaque-debug" version = "0.3.0" @@ -6879,6 +6976,34 @@ dependencies = [ "time", ] +[[package]] +name = "plotters" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c224ba00d7cadd4d5c660deaf2098e5e80e07846537c51f9cfa4be50c1fd45" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e76628b4d3a7581389a35d5b6e2139607ad7c75b17aed325f210aa91f4a9609" + +[[package]] +name = "plotters-svg" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f6d39893cca0701371e3c27294f09797214b86f1fb951b89ade8ec04e2abab" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.16.8" @@ -7794,6 +7919,7 @@ version = "0.1.0" dependencies = [ "arrayvec 0.7.4", "bromberg_sl2", + "criterion", "gpui", "log", "rand 0.8.5", @@ -9831,6 +9957,16 @@ dependencies = [ "url", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.6.0" diff --git a/crates/rope/Cargo.toml b/crates/rope/Cargo.toml index b1ce369416..577fbf9c0d 100644 --- a/crates/rope/Cargo.toml +++ b/crates/rope/Cargo.toml @@ -23,3 +23,8 @@ util.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true util = { workspace = true, features = ["test-support"] } +criterion = { version = "0.4", features = ["html_reports"] } + +[[bench]] +name = "rope_benchmark" +harness = false diff --git a/crates/rope/benches/rope_benchmark.rs b/crates/rope/benches/rope_benchmark.rs new file mode 100644 index 0000000000..a1d04474d3 --- /dev/null +++ b/crates/rope/benches/rope_benchmark.rs @@ -0,0 +1,144 @@ +use std::ops::Range; + +use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion, Throughput}; +use rand::prelude::*; +use rand::rngs::StdRng; +use rope::Rope; +use util::RandomCharIter; + +fn generate_random_text(mut rng: StdRng, text_len: usize) -> String { + RandomCharIter::new(&mut rng).take(text_len).collect() +} + +fn generate_random_rope(rng: StdRng, text_len: usize) -> Rope { + let text = generate_random_text(rng, text_len); + let mut rope = Rope::new(); + rope.push(&text); + rope +} + +fn generate_random_rope_ranges(mut rng: StdRng, rope: &Rope) -> Vec> { + let range_max_len = 50; + let num_ranges = rope.len() / range_max_len; + + let mut ranges = Vec::new(); + let mut start = 0; + for _ in 0..num_ranges { + let range_start = rope.clip_offset( + rng.gen_range(start..=(start + range_max_len)), + sum_tree::Bias::Left, + ); + let range_end = rope.clip_offset( + rng.gen_range(range_start..(range_start + range_max_len)), + sum_tree::Bias::Right, + ); + + let range = range_start..range_end; + if !range.is_empty() { + ranges.push(range); + } + + start = range_end + 1; + } + + ranges +} + +fn rope_benchmarks(c: &mut Criterion) { + static SEED: u64 = 9999; + static KB: usize = 1024; + + let rng = StdRng::seed_from_u64(SEED); + let sizes = [4 * KB, 64 * KB]; + + let mut group = c.benchmark_group("push"); + for size in sizes.iter() { + group.throughput(Throughput::Bytes(*size as u64)); + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| { + let text = generate_random_text(rng.clone(), *size); + + b.iter(|| { + let mut rope = Rope::new(); + for _ in 0..10 { + rope.push(&text); + } + }); + }); + } + group.finish(); + + let mut group = c.benchmark_group("append"); + for size in sizes.iter() { + group.throughput(Throughput::Bytes(*size as u64)); + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| { + let mut random_ropes = Vec::new(); + for _ in 0..5 { + random_ropes.push(generate_random_rope(rng.clone(), *size)); + } + + b.iter(|| { + let mut rope_b = Rope::new(); + for rope in &random_ropes { + rope_b.append(rope.clone()) + } + }); + }); + } + group.finish(); + + let mut group = c.benchmark_group("slice"); + for size in sizes.iter() { + group.throughput(Throughput::Bytes(*size as u64)); + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| { + let rope = generate_random_rope(rng.clone(), *size); + + b.iter_batched( + || generate_random_rope_ranges(rng.clone(), &rope), + |ranges| { + for range in ranges.iter() { + rope.slice(range.clone()); + } + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); + + let mut group = c.benchmark_group("bytes_in_range"); + for size in sizes.iter() { + group.throughput(Throughput::Bytes(*size as u64)); + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| { + let rope = generate_random_rope(rng.clone(), *size); + + b.iter_batched( + || generate_random_rope_ranges(rng.clone(), &rope), + |ranges| { + for range in ranges.iter() { + let bytes = rope.bytes_in_range(range.clone()); + assert!(bytes.into_iter().count() > 0); + } + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); + + let mut group = c.benchmark_group("chars"); + for size in sizes.iter() { + group.throughput(Throughput::Bytes(*size as u64)); + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| { + let rope = generate_random_rope(rng.clone(), *size); + + b.iter_with_large_drop(|| { + let chars = rope.chars().count(); + assert!(chars > 0); + }); + }); + } + group.finish(); +} + +criterion_group!(benches, rope_benchmarks); +criterion_main!(benches); diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 77e86e3031..852910cacc 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -23,7 +23,7 @@ pub use unclipped::Unclipped; const CHUNK_BASE: usize = 6; #[cfg(not(test))] -const CHUNK_BASE: usize = 16; +const CHUNK_BASE: usize = 64; /// Type alias to [`HashMatrix`], an implementation of a homomorphic hash function. Two [`Rope`] instances /// containing the same text will produce the same fingerprint. This hash function is special in that