Deryks _dev

Full-Stack Web Developer & Technical Leader

<< 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.

You are not allowed a performance opinion without a number.
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 will fidget constantly, and that's correctFrame time genuinely varies frame to frame — the OS schedules your process differently, caches are warm or cold, the compositor times things slightly differently. On a small file you're reading a tenth of a millisecond to two decimal places, so the last digits will never sit still. That's the measurement being honest, not a bug.

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| {
Order decides the layout — and it is not negotiablePanels claim space in the order you declare them, and 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:

FileIdleTypingTyping, --release
100 lines0.10 ms0.68 ms0.04 ms
1,000 lines0.25 ms1.89 ms0.15 ms
10,000 lines1.86 ms14.46 ms1.44 ms
50,000 lines9.29 ms70.61 ms7.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.

The single most useful number in this lessonA debug build is ~10× slower than a release build. Rust's debug profile turns off optimisation entirely and keeps every bounds check and overflow check. If you ever catch yourself thinking "Rust is supposed to be fast, why is this crawling" — check which profile you're in before you change a single line of code.

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.

SuspectMeasuredShare of a 7.10 ms frame
String::insert (the buffer)0.011 ms0.2%
line_col (our code)0.875 ms12%
egui laying out all the text~6.2 ms~87%
I predicted the wrong bottleneck, in writingThe plan for this lesson said we'd "feel 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:

  1. Use --release when you care about speed. Free, and the largest single factor at every file size.
  2. 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.
  3. The buffer is fine, for now. String stops 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.
The middle of the file is unchangedBetween them sit 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?

  • Whether you are running a release build
  • Whether the buffer should become a rope
  • Whether the file has very long single lines
  • Whether the status bar is redrawn too often

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?

  • Around 11 microseconds — negligible here
  • Around 11 milliseconds — most of the frame
  • Around 110 milliseconds — visibly janky
  • It depends entirely on available system memory

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()?

  • Idle egui stops repainting, freezing the number
  • Repainting resets the timer between two frames
  • Labels cache their text until explicitly invalidated
  • The clock only advances while a widget has focus

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.