cessen / ropey

A utf8 text rope for manipulating and editing large texts.
MIT License
1.04k stars 46 forks source link

Lines iterated ropey is ~2x slower when highlighting ~1k lines of code than a simple Vec of Vec of chars #87

Closed ivanceras closed 1 year ago

ivanceras commented 1 year ago

I have a simple test to check the time it took for highlighting a line returned from a Rope and from a simple TextBuffer. It seems that highlighting lines coming from Rope is almost 2x slower.

The TextBuffer at it's core is just a Vec<Vec<Ch>> and Ch is just:

pub struct Ch {
    pub ch: char,
    // the unicode width of the character
    pub width: usize,
}

I'm expecting ropey to be faster than a simple Vec of Vec of chars structure. The width here is needed because I present the lines visually in rendering the text which depends on the unicode of the character.

This is the code for iterating and highlighting the lines for each implementation.

pub fn text_edit_highlighting() -> usize {
    let mut text_highlighter = TextHighlighter::default();
    text_highlighter.set_syntax_token("rust");
    let text_edit = TextEdit::new_from_str(CODE);
    let result: Vec<Vec<(Style, Vec<Ch>)>> = text_edit
        .lines()
        .iter()
        .map(|line| {
            text_highlighter
                .highlight_line(line)
                .expect("must highlight")
                .into_iter()
                .map(|(style, line)| (style, line.chars().map(Ch::new).collect()))
                .collect()
        })
        .collect();
    result.len()
}

pub fn ropey_highlighting() -> usize {
    let mut text_highlighter = TextHighlighter::default();
    text_highlighter.set_syntax_token("rust");

    let rope = Rope::from_str(CODE);

    let result: Vec<Vec<(Style, Vec<Ch>)>> = rope
        .lines()
        .map(|line| {
            let line_str = String::from_iter(line.chars());
            text_highlighter
                .highlight_line(&line_str)
                .expect("must highlight")
                .into_iter()
                .map(|(style, line)| (style, line.chars().map(Ch::new).collect()))
                .collect()
        })
        .collect();
    result.len()
}

And the test to see which one is much faster.

// This is a simple test to determine whether ropey or a simple Vec<Vec<Char>> is much faster to
// Tested on: Intel(R) Core(TM) i7-3770K CPU @ 3.50GHz
// - ropey : 2421ms
// - TextEdit: 1506ms
//
// on AMD® Ryzen 5 5600h
//
// `cargo test`:
// took: 1814ms highlighting 1632 lines iterated from ropey
// took: 1077ms highlighting 1632 lines iterated from TextEdit
//
// `cargo test --release`
// took: 140ms highlighting 1632 lines iterated from ropey
// took: 79ms highlighting 1632 lines iterated from TextEdit
//
//
#[test]
fn text_edit_is_faster_than_ropey() {
    let t1 = Instant::now();
    let len = ropey_highlighting();
    let ropey_took = t1.elapsed().as_millis();
    println!("took: {ropey_took}ms highlighting {len} lines iterated from ropey");
    let t2 = Instant::now();
    text_edit_highlighting();
    let textedit_took = t2.elapsed().as_millis();
    println!("took: {textedit_took}ms highlighting {len} lines iterated from TextEdit");

    assert!(textedit_took < ropey_took);
}

To quickly replicate this test:

git clone --depth=1 https://github.com/ivanceras/ultron
cd ultron
cargo test --release --test compare_ropey
cessen commented 1 year ago

This doesn't surprise me:

  1. Your TextBuffer structure is close to ideal for the kind of iteration you're doing. You're iterating over lines, and then over the chars in each line. And TextBuffer has already done all of the processing to split the lines and generate the chars. So you basically have no overhead other than the iterating over arrays, which is extremely fast.
  2. Ropey, on the other hand, has to find the lines during iteration, which involves tree traversal and scanning for line breaks (and by default it scans for all eight Unicode-specified line breaks!). Additionally, Ropey stores text as utf8 text, which has to be converted to chars on the fly as it iterates. And additionally, in your Ropey-based code but not your TextBuffer-based code, you're making a full copy of each line via the chars iterator (let line_str = String::from_iter(line.chars());), which is not exactly efficient.

So I'm not sure why you would expect Ropey to be faster here. You've basically purpose-made a data structure for exactly this kind of processing, and are comparing against something that is instead optimized for editing large texts. That isn't to say that Ropey's line iterator couldn't be faster than it is now, but it will certainly never be faster than almost zero overhead, which is what you've built here.