<< Back / Lesson 06 / Text Editor GUI in Rust / ~35 minutes
Measure Before You Optimise
Three stages to an editor that reports its own frame time — and proves my prediction wrong.
Back inlesson 1 I promised that immediate mode would eventually bite, and that we'd have the fight around lesson 6, "when a 50,000-line file meets a naive redraw". This is that lesson.
I also wrote a plan predicting how it would go: feel String insertion hurt on a large file, then reach for a rope data structure. That prediction was wrong, and I only know it's wrong because I measured. Fixing what you assumed is slow is the most reliable way to spend a week making no difference.
Not a benchmark someone else ran, not a rule of thumb — a number your own program printed, on your machine, doing your work.
Where you're starting
Lesson 5's 97 lines, opening any file from the command line. Check againstlesson 5's checkpoint.
adding removing existing code, shown only for context
Give it a stopwatch
The editor reports how long its own frame took.
Five edits. First, the standard library's clock:
editor/src/main.rstop of the file
use std::io;
use std::time::Instant;Second and third — somewhere to keep the measurement, and a starting value:
editor/src/main.rsthe Editor struct
status: String,
frame_ms: f32,
}editor/src/main.rsin Editor::open
text,
status,
frame_ms: 0.0,
}Fourth — wrap the frame. Note that two of these lines go outside the panel closure, one at the very top of ui and one at the very bottom:
editor/src/main.rsin fn ui, around the panel
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let frame_start = Instant::now();
ui.ctx().request_repaint();
egui::CentralPanel::default().show(ui, |ui| {
everything already inside the central panel, unchanged
});
self.frame_ms = frame_start.elapsed().as_secs_f32() * 1000.0;
}Fifth — show it, in the byte-count label you already have:
editor/src/main.rsin fn ui, the status label
ui.label(format!("{position} · {} bytes", self.text.len()));
ui.label(format!(
"{position} · {} bytes · {:.2} ms/frame",
self.text.len(),
self.frame_ms
));Why it shows the previous frame
frame_ms is written after the panel has been drawn, so the label you just built is showing last frame's number. It can't show its own — the frame isn't over until after the label exists. Every frame-time readout in every program works this way.
request_repaint() is the other half. Rememberlesson 1: egui only repaints when something happens, so an idle editor would freeze the number at whatever it was when you last touched a key. Asking for a repaint every frame turns that off, which is exactly what you want while measuring and exactly what you'd remove afterwards — it burns CPU forever, which is the thing lesson 1 praised egui for avoiding.
cargo run
→ Ln 1, Col 1 · 31 bytes · 0.09 ms/frame
Small file, tiny number. That's the control.
It also means the exact value is not the thing to read. Watch for the jump — the difference between resting and typing, and how that difference grows with file size. That difference is what stages 2 and 3 are about, and it's far larger than the noise.
Make the readout reachable
The status line stays put at the bottom of the window, whatever the file.
Before feeding it a huge file, try scrolling to the end of a merely large one. You can't — and the readout you just built lives below the text box, so on anything longer than the window it is pushed off-screen and unreachable. An instrument you can't read isn't an instrument.
Lesson 1 promised this fix: "later you'll add a top panel for tabs and a bottom one for a status bar, and the central panel will shrink to fit around them automatically." Four edits, and this is surgery — the file will not compile until all four are in. Do them all, then run.
First — the editor needs to remember the position between frames, because the status bar is now drawn before the text box that computes it:
editor/src/main.rsthe Editor struct
status: String,
position: String,
frame_ms: f32,Second — start it off with something sensible:
editor/src/main.rsin Editor::open
status,
position: "no cursor".to_owned(),
frame_ms: 0.0,Third — move both labels out of the central panel and into a bottom panel, declaredabove it:
editor/src/main.rsin fn ui, above the central panel
ui.ctx().request_repaint();
egui::Panel::bottom("status").show(ui, |ui| {
ui.label(format!(
"{} · {} bytes · {:.2} ms/frame",
self.position,
self.text.len(),
self.frame_ms
));
ui.label(&self.status);
});
egui::CentralPanel::default().show(ui, |ui| {CentralPanel takes whatever is left. Declare the bottom panel after the central panel and it gets a height of zero pixels — no error, no warning, it simply never appears. I measured exactly that before writing this stage.Fourth — delete the two old labels from the bottom of the central panel, wrap the text box in a scroll area, and store the position instead of printing it:
editor/src/main.rsin fn ui, the text box and the old labels
egui::ScrollArea::vertical().show(ui, |ui| {
let output = egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY)
.show(ui);
let position = match output.cursor_range {
self.position = match output.cursor_range {
the two match arms, unchanged — just indented one level further
};
ui.label(format!("{position} · {} bytes", self.text.len()));
});
});The status bar now shows the cursor position from the previousframe, because it's drawn first. That's the same one-frame lagframe_ms already had, for the same reason, and at 60 frames a second nobody has ever noticed.
cargo run
→ the status line sits at the bottom of the window and stays there
→ the text scrolls, and the readout doesn't move
Feed it something enormous
Watch the number move. No code — this stage is data collection.
Make a genuinely large source file — 50,000 lines, about 2 MB:
cd editor
seq 1 50000 | awk '{print " let value_" $1 " = compute(" $1 ") + 1;"}' > big.rs
wc -l big.rs
50000 big.rs — and it's disposable, delete it when you're done.
cargo run -- big.rs
→ scroll around, then type a character and watch the number
Two numbers matter, and they're very different. Idle is what you get while the text sits unchanged. Typing is what you get on the frames where the text changed. Here's what I measured on this machine:
| File | Idle | Typing | Typing, --release |
|---|---|---|---|
| 100 lines | 0.10 ms | 0.68 ms | 0.04 ms |
| 1,000 lines | 0.25 ms | 1.89 ms | 0.15 ms |
| 10,000 lines | 1.86 ms | 14.46 ms | 1.44 ms |
| 50,000 lines | 9.29 ms | 70.61 ms | 7.10 ms |
A frame at 60 fps has 16.7 ms. So in the build you've been running all along, a 50,000-line file gives you about 14 frames per second while typing — the editor visibly lags behind your fingers.
Now run the same thing optimised:
cargo run --release -- big.rs
First build takes a minute — it's recompiling all 414 crates with optimisation.
Before you look: how much faster do you expect the optimised build to be? Commit to a number.
About ten times, and it's roughly ten times at every size. 70.61 ms becomes 7.10 ms; 14.46 becomes 1.44. Most people guess two or three.
This is also why performance complaints about Rust programs are worth one question before any other: release build? It's the same reason the toolchain reference tells you a debug-build editor feeling sluggish on a big file is not a bug.
Find out where the time actually goes
Time your own code separately, and stop guessing.
7 ms per keystroke in release is still 43% of a frame. Something is doing real work. The obvious suspect is line_col from lesson 4 — it walks the text from the start on every single frame, which is exactly the kind of thing that should scale badly. So measure it rather than assuming. Four edits.
editor/src/main.rsthe Editor struct
frame_ms: f32,
line_col_us: f32,
}editor/src/main.rsin Editor::open
frame_ms: 0.0,
line_col_us: 0.0,
}editor/src/main.rsin fn ui, the Some arm inside the scroll area
Some(range) => {
let started = Instant::now();
let (line, column) = self.line_col(range.primary.index.0);
self.line_col_us = started.elapsed().as_secs_f32() * 1_000_000.0;
format!("Ln {line}, Col {column}")
}Fourth — microseconds this time, not milliseconds — if you measure a small thing in big units you get 0.00 and learn nothing. Add it to the label:
editor/src/main.rsin fn ui, the label in the bottom panel
ui.label(format!(
"{} · {} bytes · {:.2} ms/frame",
"{} · {} bytes · line_col {:.0} µs · {:.2} ms/frame",
self.position,
self.text.len(),
self.line_col_us,
self.frame_ms
));cargo run --release -- big.rs
→ press Ctrl+End to put the cursor at the very bottom, then type
→ … line_col 875 µs · 7.10 ms/frame
Read the two numbers together
line_col costs 0.875 ms of a 7.10 msframe. It is about an eighth of the cost. Real, worth fixing eventually — and nowhere near the thing that's making the editor slow.
So what is? Everything else in that frame, and almost all of it is one thing: egui lays out the entire 2 MB of text — every glyph position, every line wrap — every time the text changes. It doesn't know that you only changed one character near the top, and it doesn't know that you can only see forty lines of it.
| Suspect | Measured | Share of a 7.10 ms frame |
|---|---|---|
String::insert (the buffer) | 0.011 ms | 0.2% |
line_col (our code) | 0.875 ms | 12% |
| egui laying out all the text | ~6.2 ms | ~87% |
String insertion hurt on a large file" and then reach for a rope. Inserting a character into the middle of a 2 MBString takes 11 microseconds — 0.2% of the frame. It copies a megabyte, and copying a megabyte is just not slow on modern hardware. Had I followed the plan, you'd have spent a lesson replacing a data structure that was never the problem, and the editor would have felt exactly as slow afterwards.This is the whole reason the lesson exists. The rope isn't wrong — it becomes right at a size and an edit pattern we haven't reached. But "obviously O(n), therefore obviously the bottleneck" is the most expensive kind of obvious.
O(n), said "order n", is shorthand for how an operation's cost grows with the size of its input — O(n) means double the text, double the work. String::insert is O(n) because everything after the cursor shifts along to make room.
The trap is that big-O describes the shape of that growth, not its height: it throws away the cost of each unit of work, which is the part that decides whether you notice. Look again at the table above — egui's text layout is O(n) in the file size too. Both suspects were linear, so the complexity class could not tell them apart at all — and the one that mattered cost some 560 times more per frame, because shaping a glyph and deciding where a line wraps is vastly more work than copying a byte. What big-O is good for is predicting what breaks later — it's a claim about scaling, not a measurement of what you have now.
rm editor/big.rs
cargo fmt && cargo clippy
Both silent. Keep the instrumentation — lesson 7 needs it.
What we do about it
Three findings, in the order they're worth acting on:
- Use
--releasewhen you care about speed. Free, and the largest single factor at every file size. - egui's full-text layout is the real cost. There's no flag for this. The textbook fix is to draw only the lines on screen — but that means painting text ourselves, and giving up egui's text input, selection and clipboard along with it. Weighed against a 7 ms frame at 50,000 lines and under 2 ms at 10,000, that trade isn't worth making yet. We'll revisit it if a measurement forces the issue.
- The buffer is fine, for now.
Stringstops being fine when edits stop being cheap: many megabytes, or edits far from the end, or undo history wanting cheap snapshots. We'll swap it when a measurement says to, and you'll have the measurement because you just built the instrument.
Where you ended up
121 lines in total. Two regions changed — the struct at the top and the whole offn ui — so they're shown separately.
editor/src/main.rsthe top of the file
use eframe::egui;
use std::env;
use std::fs;
use std::io;
use std::time::Instant;
struct Editor {
path: String,
text: String,
status: String,
position: String,
frame_ms: f32,
line_col_us: f32,
}
impl Editor {
/// Open a file into a new editor. A file that doesn't exist yet is not an
/// error — it's a new file, and starts empty.impl Editor's three methods. open gains the three new fields in its Self { … }; save andline_col are untouched fromlesson 5.editor/src/main.rsthe whole of fn ui
impl eframe::App for Editor {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let frame_start = Instant::now();
ui.ctx().request_repaint();
egui::Panel::bottom("status").show(ui, |ui| {
ui.label(format!(
"{} · {} bytes · line_col {:.0} µs · {:.2} ms/frame",
self.position,
self.text.len(),
self.line_col_us,
self.frame_ms
));
ui.label(&self.status);
});
egui::CentralPanel::default().show(ui, |ui| {
let save_requested = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S));
if save_requested && let Err(error) = self.save() {
self.status = format!("save failed: {error}");
}
ui.heading(&self.path);
egui::ScrollArea::vertical().show(ui, |ui| {
let output = egui::TextEdit::multiline(&mut self.text)
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY)
.show(ui);
self.position = match output.cursor_range {
Some(range) => {
let started = Instant::now();
let (line, column) = self.line_col(range.primary.index.0);
self.line_col_us = started.elapsed().as_secs_f32() * 1_000_000.0;
format!("Ln {line}, Col {column}")
}
None => "no cursor".to_owned(),
};
});
});
self.frame_ms = frame_start.elapsed().as_secs_f32() * 1000.0;
}
}121 lines, cargo clippy silent.
Check yourself
Your editor crawls on a 2 MB file. What do you check before changing any code?
Debug builds measured ~10× slower at every file size — 70.61 ms per keystroke versus 7.10 ms at 50,000 lines. It's the largest single factor and it costs nothing to check, so it comes before any code change.↑ Review: the measurements (stage 2)
Inserting a character into the middle of a 2 MB String copies about a megabyte. How long does that take?
11 µs, roughly 0.2% of the frame. Copying a megabyte is genuinely cheap on modern hardware, so an O(n) operation — one whose cost grows in step with the file — can still be linear and negligible. "Obviously O(n), therefore obviously the bottleneck" is exactly the reasoning this lesson exists to break.↑ Review: where the time goes (stage 3)
From lesson 1: why does the frame-time readout need request_repaint()?
egui repaints on interaction or animation, so an untouched editor draws no frames and the number sits at whatever it last was. Forcing a repaint every frame is right while measuring and wrong afterwards — it spends CPU forever.↑ Review: when egui repaints (lesson 1, stage 6)
Read this before lesson 7
Primary source:The Rust Performance Book — Profiling, by Nicholas Nethercote.
Nethercote spent years making rustc itself faster, and the book opens where this lesson did: measure first, and measure the right build. The profiling chapter is short and lists the tools worth knowing — perf, samply,cargo-flamegraph — for when a hand-placed Instant::now() stops being enough. Read the profiling chapter and the "Build Configuration" one; both are a few minutes each.