如何提高 Rust 程序的性能?

作者 | Aram Drevekenin

譯者 | 馬超     

出品 | CSDN(ID:CSDNnews)

Zellij 是一款非常優秀的終端工作區和多路複用器(類似於 tmux 和 screen),由於使用 Rust 語言開發,因此與 Zellij 與 WebAssembly 原生兼容。筆者注意到在過去的幾個月中,Zellij 的開發者一直在對 Zellij 進行優化與排坑,他們發佈了一些很多意義的技術博客來記錄整個優化過程。博客中展示了一些非常值得總結和重視的問題,通過他們的分享我們可以看到,Zellij 的開發者們提出了很多創造性的解決方案。通過兩個主要的技術提升點,他們大幅調優了 Zellij 在大量顯示刷新場景下的性能。下面我把相關技術博客爲大家進行解讀。

由於 Zellij 是一個非常龐大的應用程序,其實際代碼非常複雜,細摳所有技術細節,可能會把讀者完全繞暈。因此本文使用的代碼示例都是簡化後的版本,僅用於討論問題的示例。

問題一的描述

Zellij 是一個終端多路複用器,它允許用戶創建多個 “選項卡” 和“窗口”,Zellij 會爲每個終端窗口進行狀態保持,其中狀態信息包括文本、樣式以及窗口內光標位置等要素。這種設計可以方便用戶每次連接到現有會話時都保證用戶體驗的一致性,並可以支持用戶在內部選項卡之間自由切換。不過狀態在之前版本中 Zellij 窗口中顯示大量數據時,性能問題會非常明顯。例如,cat 輸入一個非常大的文件,這時 Zellij 會比裸終端仿真器慢得多,甚至比與其他終端多路複用器也慢。下面筆者將帶着大家共同深入研究這個問題。

問題一巨大流量的衝擊

=================

Zellij 使用多線程架構,PTY 線程和 Screen 渲染線程執行特定任務並通過 MPSC 通道互相通信。其中 PTY 線程查詢 PTY,也就是用戶屏幕上的輸入、輸出,並將原始數據發送到 Screen 線程。該線程解析數據並建立終端窗口的內部狀態。PTY 線程會將終端的狀態呈現到用戶屏幕上,並向 Screen 線程發送渲染請求。

PTY 線程不斷輪詢 PTY,以查看它在異步數據接收的 while 循環中是否有新數據。如果沒有接收到數據,則休眠一段固定的時間。簡單的講 PTY 線程會在以下任一情況下發生時發送數據:

1.PTY 讀取緩衝區中沒有更多數據

  1. 最後一條屏幕刷新指令已經被執行了 30 毫秒或更長時間。

第二種設計是出於用戶體驗的原因。這樣,如果 PTY 有大量數據流,用戶將在屏幕上實時看到這些數據的更新。

讓我們看一下代碼:

task::spawn({
    async move {
        // TerminalBytes是異步數據流     let mut terminal_bytes = TerminalBytes::new(pid);
        let mut last_render = Instant::now();
        let mut pending_render = false;
        let max_render_pause = Duration::from_millis(30);
        while let Some(bytes) = terminal_bytes.next().await {
            let receiving_data = !bytes.is_emPTY();
            if receiving_data {
                send_data_to_screen(bytes);
                pending_render = true;
            }
            if pending_render && last_render.elapsed() > max_render_pause {
                send_render_to_screen();
                last_render = Instant::now();
                pending_render = false;
            }
            if !receiving_data {
                   task::sleep(max_render_pause).await;
            }
        }
    }
})

解決問題

===========

爲了測試這個大規模顯示流程的性能,我們 cat 了一個 2,000,000 行的文件,並使用 hyperfine 基準測試工具,並使用 --show-output 參數來測試標準輸出場景,並使用 tmux 進行對比。

hyperfine --show-output "cat /tmp/bigfile" 在 tmux 內運行的結果:(窗口大小:59 行,104 列)

Time (mean ± σ):      5.593 s ±  0.055 s    [User: 1.3 ms, System: 2260.6 ms]
Range (min … max):    5.526 s …  5.678 s    10 runs

hyperfine --show-output "cat /tmp/bigfile" 在 Zellij 內部運行的結果:(窗口大小:59 行,104 列)

Time (mean ± σ):     19.175 s ±  0.347 s    [User: 4.5 ms, System: 2754.7 ms]
Range (min … max):   18.647 s … 19.803 s    10 runs

可以看到優化前 tmux 的性能幾乎是 Zellij 的 8 倍多。

第一個問題點:MPSC 通道溢出

第一個性能問題是 MPSC 通道的溢出,由於 PTY 線程和屏幕線程之間沒有同步控制,PTY 進程發送數據的速度要遠比 Screen 線程處理數據的速度要快很多。PTY 和 SCREEN 之間的不平衡將在以下幾個方面影響性能:

  1. 通道緩衝區空間不斷增長,佔用越來越多的內存

  2. 屏幕線程渲染的次數遠比合理值要高,因爲屏幕線程需要越來越多的時間來處理隊列中的消息。

問題一的解決之道,將 MPSC 轉換爲有界通道

這個緊迫問題的解決方案是限制通道的緩衝區大小,並由此在兩個線程之間創建同步關係。爲此開發者們放棄了 MPSC 而選擇了有界同步通道 crossbeam,crossbeam 提供了一個非常有用的宏 select!。此外,開發者們還刪除了自定義的後臺輪詢的異步流實現,轉而使用 async_stdFile 以獲得 “異步 i/o” 效果。

我們來看看代碼中的變化:

task::spawn({
    async move {
        let render_pause = Duration::from_millis(30);
        let mut render_deadline = None;
        let mut buf = [0u8; 65536];
           let mut async_reader = AsyncFileReader::new(pid);    // 用async_std實現異步IO
//以下是異步實現在deadline時進行特殊處理
        loop {
                  match deadline_read(&mut async_reader, render_deadline, &mut buf).await {
                ReadResult::Ok(0) | ReadResult::Err(_) => break, // EOF or error                ReadResult::Timeout => {
                    async_send_render_to_screen(bytes).await;
                    render_deadline = None;
                }
                ReadResult::Ok(n_bytes) => {
                    let bytes = &buf[..n_bytes];
                    async_send_data_to_screen(bytes).await;
                    render_deadline.get_or_insert(Instant::now() + render_pause);
                }
            }
        }
    }
})

所以這或多或少是事後的樣子:

性能改進

讓我們回到最初的性能測試。

以下是運行時的數字 hyperfine --show-output "cat /tmp/bigfile"(窗格大小:59 行,104 列):

# Zellij before this fix
Time (mean ± σ):     19.175 s ±  0.347 s    [User: 4.5 ms, System: 2754.7 ms]
Range (min … max):   18.647 s … 19.803 s    10 runs
# Zellij after this fix
Time (mean ± σ):      9.658 s ±  0.095 s    [User: 2.2 ms, System: 2426.2 ms]
Range (min … max):    9.433 s …  9.761 s    10 runs
# Tmux
Time (mean ± σ):      5.593 s ±  0.055 s    [User: 1.3 ms, System: 2260.6 ms]
Range (min … max):    5.526 s …  5.678 s    10 runs

雖然有了近一倍的性能提升,但從 Tmux 的數據來看,Zellij 仍然可以做得更好。

第二個問題,提高渲染和數據解析的性能

接下來開發者們又將管道綁定到屏幕線程,如果提高屏幕線程中兩個相關作業的性能,能夠使整個過程運行得更快:解析數據並將其渲染到用戶終端。屏幕線程的數據解析部分的作用是將 ANSI/VT 等控制指令(如 \ r\n 這樣的回車或者換行符)轉化爲 Zellij 可以控制的數據結構。

以下是這些數據結構的相關部分:

struct Grid {
    viewport: Vec,
    cursor: Cursor,
    width: usize,
    height: usize,
}struct Row {
    columns: Vec,
}struct Cursor {
    x: usize,
    y: usize
}#[derive(Clone, Copy)]struct TerminalCharacter {
    character: char,
    styles: CharacterStyles
}

預分配內存

解析器執行最頻繁的操作就是給一行文字內添加顯示的字符。特別是在行尾添加字符。這個動作主要涉及將那些 TerminalCharacters 推入到列向量中。每個推送都涉及一個從堆上分配一段內存空間,這個內存分配的操作是非常耗時的,這點筆者在之前的博客《一行無用的枚舉代碼,卻讓 Rust 性能提升 10%》中有過介紹。因此可以通過在每次創建行或調整終端窗口大小時預分配內存,來獲得性能上的提升。所以開發者們從改變 Row(行)類的構造函數開始:

impl Row {
    pub fn new() -> Self {
        Row {
            columns: Vec::new(),
        }
    }}
}

對此:

impl Row {
    pub fn new(width: usize) -> Self {
        Row {
            columns: Vec::with_capacity(width),//通過指定capacity來預分配一段內存
        }
    }}
}

緩存字符寬度

我們知道一些特殊的字符比如中文全角字符會比普通的英文字符佔用更多的空間。這方面 Zellij 又引入了 unicode-width crate 來計算每個字符的寬度。

在 Zellij 給一行內容中添加字符時,終端仿真器需要知道該行的當前寬度,以便決定是否應該將字符換行到下一行。所以它需要不斷地查看和累加行中前一個字符的寬度。因爲需要找到一個計算字符寬度的方法。

代碼如下:

#[derive(Clone, Copy)]struct TerminalCharacter {
    character: char,
    styles: CharacterStyles
}impl Row {
    pub fn width(&self) -> usize {
        let mut width = 0;
        for terminal_character in self.columns.iter() {
            width += terminal_character.character.width();
        }
        width
    }
}

加入緩存之後速度變得更快:

#[derive(Clone, Copy)]struct TerminalCharacter {
    character: char,
    styles: CharacterStyles,
    width: usize,
}impl Row {
    pub fn width(&self) -> usize {
        let mut width = 0;
        for terminal_character in self.columns.iter() {
            width += terminal_character.width;
        }
        width
    }
}

渲染速度提升

Screen 線程的渲染部分本質上執行與數據解析部分反向操作。它獲取由上述數據結構表示的每個窗口狀態,並將其轉換爲 ANSI/VT 的控制指令,以發送到操作系統自身的終端仿真器並對其解釋執行。也就是說對於普通字符就進行顯示渲染,如果是控制符則發給系統 shell 執行。

fn render(&mut self) -> String {
    let mut vte_output = String::new();
    let mut character_styles = CharacterStyles::new();
    let x = self.get_x();
    let y = self.get_y();
    for (line_index, line) in grid.viewport.iter().enumerate() {
        vte_output.push_str(
            // goto row/col and reset styles            &format!("\u{1b}[{};{}H\u{1b}[m", y + line_index + 1, x + 1)
        );
        for (col, t_character) in line.iter().enumerate() {
            let styles_diff = character_styles
                .update_and_return_diff(&t_character.styles);
            if let Some(new_styles) = styles_diff {
            vte_output.push_str(&new_styles);                // 如果不是一類字符,則在此替換處理
            }
            vte_output.push(t_character.character);
        }
     character_styles.clear();
    }
    vte_output
}

我們知道 STDOUT 寫入是一種非常耗費性能的操作,爲此開發者們再次寄出緩衝區這個神器。該緩衝區主要跟蹤最新與次新渲染請求的差異,最終只將緩衝區內這些不同的差異部分進行渲染。

代碼如下:

#[derive(Debug)]pub struct CharacterChunk {
    pub terminal_characters: Vec,
    pub x: usize,
    pub y: usize,
}#[derive(Clone, Debug)]pub struct OutputBuffer {
    changed_lines: Vec, // line index    should_update_all_lines: bool,
}impl OutputBuffer {
    pub fn update_line(&mut self, line_index: usize) {
        self.changed_lines.push(line_index);
    }
    pub fn clear(&mut self) {
        self.changed_lines.clear();
    }
    pub fn changed_chunks_in_viewport(
        &self,
        viewport: &[Row],
    ) -> Vec{
        let mut line_changes = self.changed_lines.to_vec();
        line_changes.sort_unstable();
        line_changes.dedup();
        let mut changed_chunks = Vec::with_capacity(line_changes.len());
        for line_index in line_changes {
            let mut terminal_characters: Vec= viewport
                .get(line_index).unwrap().columns
                .iter()
                .copied()
                .collect();
            changed_chunks.push(CharacterChunk {
                x: 0,
                y: line_index,
                terminal_characters,
            });
        }
        changed_chunks
    }
}}

我們看到這個實現最小修改單位是行,還有進一步優化爲僅修改行內部分變動字符的方案,這種方案大幅雖然增加了複雜性,不過也帶來了非常顯着的性能提升。

以下爲對比測試結果:

hyperfine --show-output "cat /tmp/bigfile" 修復後運行結果:(窗格大小:59 行,104 列)

# Zellij before all fixes
Time (mean ± σ):     19.175 s ±  0.347 s    [User: 4.5 ms, System: 2754.7 ms]
Range (min … max):   18.647 s … 19.803 s    10 runs
# Zellij after the first fix
Time (mean ± σ):      9.658 s ±  0.095 s    [User: 2.2 ms, System: 2426.2 ms]
Range (min … max):    9.433 s …  9.761 s    10 runs
# Zellij after the second fix (includes both fixes)
Time (mean ± σ):      5.270 s ±  0.027 s    [User: 2.6 ms, System: 2388.7 ms]
Range (min … max):    5.220 s …  5.299 s    10 runs
# Tmux
Time (mean ± σ):      5.593 s ±  0.055 s    [User: 1.3 ms, System: 2260.6 ms]
Range (min … max):    5.526 s …  5.678 s    10 runs

通過這一系列的改進之後,Zellij 在 cat 一個大文件時的性能已經可以和 Tmux 比肩了。

結論

=========

總結一下 Zellij 通過優化通道雙方數據處理的不平衡關係,加入緩衝並優化渲染粒度等精彩的方式大幅提升了 Zellij 多路終端複用器的性能,很多優化的思路非常值得開發者們借鑑。

原文鏈接:https://www.poor.dev/blog/performance/

聲明:本文由 CSDN 翻譯,轉載請註明來源。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pBAeKk21C3XQFsqX-MkBrA