| 1 | //! `keystroke_probe PARAGRAPHS KEYSTROKES`: types into the middle paragraph of a page |
| 2 | //! of PARAGRAPHS paragraphs and reports, per keystroke, the time, the bytes the cache |
| 3 | //! writes to disk and the bytes the section file sees: queued alone, then each one |
| 4 | //! published at once, then idle polls. The section file stays in memory so the |
| 5 | //! process's disk writes (macOS `proc_pid_rusage`) are the cache's. |
| 6 | |
| 7 | use notebook::{Remote, Replica}; |
| 8 | use onestore::{ |
| 9 | CommitError, CommitIo, ExGuid, Stamp, Transaction, |
| 10 | document::{Format, Layout}, |
| 11 | op::{Edit, Op, PageOp, SectionOp}, |
| 12 | page::{ |
| 13 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, |
| 14 | text::new_id, |
| 15 | }, |
| 16 | }; |
| 17 | use std::{ |
| 18 | io, |
| 19 | time::{Duration, Instant}, |
| 20 | }; |
| 21 | |
| 22 | fn disk_written() -> u64 { |
| 23 | // SAFETY: proc_pid_rusage fills the zeroed structure for this process. |
| 24 | unsafe { |
| 25 | let mut info: libc::rusage_info_v2 = std::mem::zeroed(); |
| 26 | libc::proc_pid_rusage( |
| 27 | libc::getpid(), |
| 28 | libc::RUSAGE_INFO_V2, |
| 29 | &mut info as *mut _ as *mut _, |
| 30 | ); |
| 31 | info.ri_diskio_byteswritten |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// The section file in memory, counting what synchronization reads and writes, so the |
| 36 | /// process's disk writes are the cache's alone. |
| 37 | struct Memory { |
| 38 | bytes: Vec<u8>, |
| 39 | read: u64, |
| 40 | written: u64, |
| 41 | } |
| 42 | |
| 43 | impl CommitIo for Memory { |
| 44 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { |
| 45 | let offset = offset as usize; |
| 46 | let size = output.len().min(self.bytes.len().saturating_sub(offset)); |
| 47 | output[..size].copy_from_slice(&self.bytes[offset..offset + size]); |
| 48 | self.read += size as u64; |
| 49 | Ok(size) |
| 50 | } |
| 51 | fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> { |
| 52 | let offset = offset as usize; |
| 53 | self.bytes |
| 54 | .resize(self.bytes.len().max(offset + bytes.len()), 0); |
| 55 | self.bytes[offset..offset + bytes.len()].copy_from_slice(bytes); |
| 56 | self.written += bytes.len() as u64; |
| 57 | Ok(bytes.len()) |
| 58 | } |
| 59 | fn flush(&mut self) -> io::Result<()> { |
| 60 | Ok(()) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | impl Remote for Memory { |
| 65 | fn read(&mut self) -> io::Result<Vec<u8>> { |
| 66 | self.read += self.bytes.len() as u64; |
| 67 | Ok(self.bytes.clone()) |
| 68 | } |
| 69 | fn stamp(&mut self) -> io::Result<Stamp> { |
| 70 | self.read += 1024; |
| 71 | Stamp::of(&self.bytes).map_err(io::Error::other) |
| 72 | } |
| 73 | fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { |
| 74 | transaction.commit(self) |
| 75 | } |
| 76 | fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { |
| 77 | onestore::confirm(self, base) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | fn paragraph(i: usize) -> PageParagraph { |
| 82 | let words = [ |
| 83 | "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog", |
| 84 | ]; |
| 85 | let text: Vec<&str> = (0..1 + i % 17).map(|j| words[(i * 7 + j) % 8]).collect(); |
| 86 | PageParagraph { |
| 87 | id: new_id().unwrap(), |
| 88 | parent: None, |
| 89 | level: 1, |
| 90 | style: None, |
| 91 | format: Format::default(), |
| 92 | content: ParagraphContent::Text(TextObject { |
| 93 | id: new_id().unwrap(), |
| 94 | date_field: None, |
| 95 | text: Paragraph::new(text.join(" "), Format::default()), |
| 96 | tags: Vec::new(), |
| 97 | }), |
| 98 | lists: Vec::new(), |
| 99 | tags: Vec::new(), |
| 100 | media: Default::default(), |
| 101 | collapsed: false, |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | fn percentiles(label: &str, mut times: Vec<Duration>, bytes: &[u64]) { |
| 106 | times.sort(); |
| 107 | let at = |q: f64| times[((times.len() - 1) as f64 * q) as usize]; |
| 108 | let mean = bytes.iter().sum::<u64>() as f64 / bytes.len() as f64; |
| 109 | let mut sorted = bytes.to_vec(); |
| 110 | sorted.sort(); |
| 111 | println!( |
| 112 | "{label}: time p50 {:.2?} p90 {:.2?} max {:.2?}; bytes mean {:.0} p50 {} max {}", |
| 113 | at(0.5), |
| 114 | at(0.9), |
| 115 | at(1.0), |
| 116 | mean, |
| 117 | sorted[sorted.len() / 2], |
| 118 | sorted[sorted.len() - 1] |
| 119 | ); |
| 120 | } |
| 121 | |
| 122 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 123 | let mut args = std::env::args().skip(1); |
| 124 | let count: usize = args.next().ok_or("PARAGRAPHS")?.parse()?; |
| 125 | let keystrokes: usize = args.next().map_or(Ok(200), |n| n.parse())?; |
| 126 | let directory = tempfile::tempdir()?; |
| 127 | // The probe page, stored in the section file before the cache opens. |
| 128 | let source = { |
| 129 | let arena = onestore::Arena::default(); |
| 130 | let mut section = |
| 131 | onestore::Section::open(&arena, onestore::create_section("probe.one", "", "Probe")?)?; |
| 132 | let creation = onestore::PageCreation::new(None, Some("Probe"), "Probe")?; |
| 133 | let page = Page { |
| 134 | title: "Probe".into(), |
| 135 | identity: None, |
| 136 | created: None, |
| 137 | margin_origin: [0.0; 2], |
| 138 | rtl: false, |
| 139 | color: None, |
| 140 | rule_lines: None, |
| 141 | objects: vec![PageObject::Outline(Outline { |
| 142 | id: new_id()?, |
| 143 | title: false, |
| 144 | min_width: None, |
| 145 | layout: Layout { |
| 146 | x: Some(36.0), |
| 147 | y: Some(86.4), |
| 148 | ..Layout::default() |
| 149 | }, |
| 150 | indents: Vec::new(), |
| 151 | paragraphs: (0..count).map(paragraph).collect(), |
| 152 | unsupported: Vec::new(), |
| 153 | })], |
| 154 | definitions: Default::default(), |
| 155 | }; |
| 156 | section.apply( |
| 157 | "Probe", |
| 158 | &Edit { |
| 159 | at: 133_000_000_000_000_000, |
| 160 | ops: vec![Op::Section(SectionOp::Import { creation, page })], |
| 161 | }, |
| 162 | )?; |
| 163 | section.seal()?; |
| 164 | section.image() |
| 165 | }; |
| 166 | println!("section {} bytes, {count} paragraphs", source.len()); |
| 167 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source)?; |
| 168 | let (space, _, _) = cache |
| 169 | .pages()? |
| 170 | .into_iter() |
| 171 | .find(|(_, title, _)| title == "Probe") |
| 172 | .ok_or("the probe page")?; |
| 173 | let target: ExGuid = cache |
| 174 | .page(space)? |
| 175 | .objects |
| 176 | .iter() |
| 177 | .find_map(|object| match object { |
| 178 | PageObject::Outline(outline) if outline.paragraphs.len() == count => { |
| 179 | outline.paragraphs[count / 2].text().map(|text| text.id) |
| 180 | } |
| 181 | _ => None, |
| 182 | }) |
| 183 | .ok_or("the probe paragraph")?; |
| 184 | let mut remote = Memory { |
| 185 | bytes: source.clone(), |
| 186 | read: 0, |
| 187 | written: 0, |
| 188 | }; |
| 189 | let mut at = 0_u32; |
| 190 | let mut keystroke = |cache: &Replica| -> Result<(), notebook::Error> { |
| 191 | cache.apply( |
| 192 | "Probe", |
| 193 | Edit { |
| 194 | at: 133_000_000_000_000_000, |
| 195 | ops: vec![Op::Page { |
| 196 | space, |
| 197 | op: PageOp::Text { |
| 198 | text: target, |
| 199 | range: at..at, |
| 200 | with: "x".into(), |
| 201 | }, |
| 202 | }], |
| 203 | }, |
| 204 | )?; |
| 205 | at += 1; |
| 206 | Ok(()) |
| 207 | }; |
| 208 | |
| 209 | let (mut times, mut bytes) = (Vec::new(), Vec::new()); |
| 210 | for _ in 0..keystrokes { |
| 211 | let (disk, start) = (disk_written(), Instant::now()); |
| 212 | keystroke(&cache)?; |
| 213 | times.push(start.elapsed()); |
| 214 | bytes.push(disk_written() - disk); |
| 215 | } |
| 216 | percentiles("queued keystroke (apply + SQLite commit)", times, &bytes); |
| 217 | |
| 218 | let (disk, read, written, start) = |
| 219 | (disk_written(), remote.read, remote.written, Instant::now()); |
| 220 | cache.sync_once(&mut remote)?; |
| 221 | println!( |
| 222 | "publish the {keystrokes} queued keystrokes as one batch: {:.2?}; cache {} B, file writes {} B, file reads {} B", |
| 223 | start.elapsed(), |
| 224 | disk_written() - disk, |
| 225 | remote.written - written, |
| 226 | remote.read - read |
| 227 | ); |
| 228 | |
| 229 | let (mut times, mut cache_bytes, mut file_bytes, mut reads) = |
| 230 | (Vec::new(), Vec::new(), Vec::new(), Vec::new()); |
| 231 | for _ in 0..keystrokes { |
| 232 | let (disk, read, written, start) = |
| 233 | (disk_written(), remote.read, remote.written, Instant::now()); |
| 234 | keystroke(&cache)?; |
| 235 | cache.sync_once(&mut remote)?; |
| 236 | times.push(start.elapsed()); |
| 237 | file_bytes.push(remote.written - written); |
| 238 | cache_bytes.push(disk_written() - disk); |
| 239 | reads.push(remote.read - read); |
| 240 | } |
| 241 | percentiles( |
| 242 | "keystroke published at once: cache", |
| 243 | times.clone(), |
| 244 | &cache_bytes, |
| 245 | ); |
| 246 | percentiles( |
| 247 | "keystroke published at once: file writes", |
| 248 | times.clone(), |
| 249 | &file_bytes, |
| 250 | ); |
| 251 | percentiles("keystroke published at once: file reads", times, &reads); |
| 252 | |
| 253 | let (disk, read) = (disk_written(), remote.read); |
| 254 | for _ in 0..100 { |
| 255 | assert!(cache.sync_once(&mut remote)?.edit.is_none()); |
| 256 | } |
| 257 | println!( |
| 258 | "100 idle polls: cache {} B, file reads {} B", |
| 259 | disk_written() - disk, |
| 260 | remote.read - read |
| 261 | ); |
| 262 | Ok(()) |
| 263 | } |