如何提高 Rust 程序的性能?

2021年8月9日 377点热度 0人点赞 0条评论

图片

作者 | 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 读取缓冲区中没有更多数据

2.最后一条屏幕刷新指令已经被执行了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 (minmax):    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而选择了有界同步通道crossbeamcrossbeam提供了一个非常有用的宏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 fixTime (mean ± σ):     19.175 s ±  0.347 s    [User: 4.5 ms, System: 2754.7 ms]Range (minmax):   18.647 s … 19.803 s    10 runs# Zellij after this fixTime (mean ± σ):      9.658 s ±  0.095 s    [User: 2.2 ms, System: 2426.2 ms]Range (minmax):    9.433 s …  9.761 s    10 runs# TmuxTime (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 fixesTime (mean ± σ):     19.175 s ±  0.347 s    [User: 4.5 ms, System: 2754.7 ms]Range (minmax):   18.647 s … 19.803 s    10 runs# Zellij after the first fixTime (mean ± σ):      9.658 s ±  0.095 s    [User: 2.2 ms, System: 2426.2 ms]Range (minmax):    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 (minmax):    5.220 s …  5.299 s    10 runs# TmuxTime (mean ± σ):      5.593 s ±  0.055 s    [User: 1.3 ms, System: 2260.6 ms]Range (minmax):    5.526 s …  5.678 s    10 runs

通过这一系列的改进之后,Zellij在cat一个大文件时的性能已经可以和Tmux比肩了。

图片

结论


总结一下Zellij通过优化通道双方数据处理的不平衡关系,加入缓冲并优化渲染粒度等精彩的方式大幅提升了Zellij多路终端复用器的性能,很多优化的思路非常值得开发者们借鉴。

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

声明:本文由CSDN翻译,转载请注明来源。 

图片

图片

图片

阿里公布女员工被侵犯事件处理结果;腾讯启动最大规模校招;字节跳动否认“重启上市计划”传闻|极客头条

Windows 11 再惹“众怒”!网友:微软就是逼我去买新电脑!

苹果新功能惹众怒,4000多家组织和个人签署公开信 敦促苹果放弃“儿童安全”功能

14290如何提高 Rust 程序的性能?

root

这个人很懒,什么都没留下

文章评论