Rust 語言的效能和安全性使其成為渲染 Mandelbrot 集等複雜圖形的理想選擇。本文首先介紹了 Mandelbrot 集的基本概念,以及如何在 Rust 中表示複數和進行畫素到複數平面的對映。接著,文章逐步展示瞭如何使用 Rust 渲染 Mandelbrot 集,並透過 pixel_to_pointrender 函式實作了核心渲染邏輯。為了進一步提升渲染效率,文章引入了平行計算的概念,利用 crossbeam 套件將渲染任務分配給多個執行緒,顯著縮短了渲染時間。最後,文章還討論了 Rust 的型別系統在確保程式安全性和效率方面的作用,以及如何編譯和執行 Mandelbrot 程式。

Rust 語言在 Mandelbrot 集渲染中的應用

Mandelbrot 集是一種著名的數學集合,具有豐富的視覺表現。在本篇文章中,我們將探討如何使用 Rust 語言渲染 Mandelbrot 集。

複數表示

Rust 語言允許我們使用 Complex { re, im } 的方式來表示複數,這種表示法與 JavaScript 和 Haskell 中的類別似。

畫素到複數的對映

在渲染 Mandelbrot 集的過程中,我們需要將影像中的畫素對映到複數平面上的點。pixel_to_point 函式實作了這一對映:

fn pixel_to_point(bounds: (usize, usize),
                  pixel: (usize, usize),
                  upper_left: Complex<f64>,
                  lower_right: Complex<f64>)
                  -> Complex<f64>
{
    let (width, height) = (lower_right.re - upper_left.re,
                          upper_left.im - lower_right.im);
    Complex {
        re: upper_left.re + pixel.0 as f64 * width / bounds.0 as f64,
        im: upper_left.im - pixel.1 as f64 * height / bounds.1 as f64
    }
}

內容解密:

  • pixel_to_point 函式接受影像的寬度和高度、畫素的座標以及複數平面上的上左和下右兩個點,傳回對應的複數。
  • widthheight 分別計算了複數平面上水平和垂直方向上的跨度。
  • reim 分別計算了畫素對應的複數的實部和虛部。
  • 使用 as f64 進行型別轉換,以確保運算的正確性。

渲染 Mandelbrot 集

render 函式負責將 Mandelbrot 集渲染到畫素緩衝區中:

fn render(pixels: &mut [u8],
          bounds: (usize, usize),
          upper_left: Complex<f64>,
          lower_right: Complex<f64>)
{
    assert!(pixels.len() == bounds.0 * bounds.1);
    for row in 0 .. bounds.1 {
        for column in 0 .. bounds.0 {
            let point = pixel_to_point(bounds, (column, row),
                                       upper_left, lower_right);
            pixels[row * bounds.0 + column] =
                match escape_time(point, 255) {
                    None => 0,
                    Some(count) => 255 - count as u8
                };
        }
    }
}

內容解密:

  • render 函式接受一個可變的畫素緩衝區、影像的寬度和高度,以及複數平面上的上左和下右兩個點。
  • 使用巢狀迴圈遍歷每個畫素,計算其對應的複數,並根據 escape_time 函式的結果設定畫素的值。
  • 如果 escape_time 傳回 None,表示該點屬於 Mandelbrot 集,將畫素設為黑色(0);否則,根據逃逸時間設定畫素的灰度值。

寫入影像檔案

使用 image 套件,可以將渲染好的畫素緩衝區寫入 PNG 影像檔案中:

fn write_image(filename: &str, pixels: &[u8], bounds: (usize, usize))
               -> Result<(), std::io::Error>
{
    let output = File::create(filename)?;
    let encoder = PNGEncoder::new(output);
    encoder.encode(&pixels,
                   bounds.0 as u32, bounds.1 as u32,
                   ColorType::Gray(8))?;
    Ok(())
}

內容解密:

  • write_image 函式接受檔名、畫素緩衝區和影像的寬度和高度,將畫素緩衝區寫入指定的 PNG 影像檔案中。
  • 使用 File::create 建立檔案,並使用 PNGEncoder 將畫素緩衝區編碼為 PNG 格式。
  • 使用 ? 運算子處理可能的錯誤,如果發生錯誤,則立即傳回錯誤碼。

平行曼德博程式

簡介

在介紹完相關的技術細節後,我們現在可以展示如何利用平行處理來加速曼德博圖形的生成。首先,我們先來看看一個簡單的非平行版本。

非平行版本

use std::io::Write;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.len() != 5 {
        writeln!(std::io::stderr(), "Usage: mandelbrot FILE PIXELS UPPERLEFT LOWERRIGHT").unwrap();
        writeln!(std::io::stderr(), "Example: {} mandel.png 1000x750 -1.20,0.35 -1,0.20", args[0]).unwrap();
        std::process::exit(1);
    }

    let bounds = parse_pair(&args[2], 'x').expect("error parsing image dimensions");
    let upper_left = parse_complex(&args[3]).expect("error parsing upper left corner point");
    let lower_right = parse_complex(&args[4]).expect("error parsing lower right corner point");

    let mut pixels = vec![0; bounds.0 * bounds.1];
    render(&mut pixels, bounds, upper_left, lower_right);
    write_image(&args[1], &pixels, bounds).expect("error writing PNG file");
}

內容解密:

  • let mut pixels = vec![0; bounds.0 * bounds.1];:建立一個向量 pixels,其長度為 bounds.0 * bounds.1,並初始化為零,用於儲存影像的畫素值。
  • render(&mut pixels, bounds, upper_left, lower_right);:呼叫 render 函式計算影像,並將結果儲存在 pixels 中。
  • write_image(&args[1], &pixels, bounds).expect("error writing PNG file");:將 pixels 中的資料寫入到指定的 PNG 檔案中。

平行版本

為了提高效率,我們可以將影像分成多個水平條帶,每個條帶由一個執行緒處理。利用 crossbeam 套件提供的 scoped thread 功能,可以輕鬆實作平行處理。

首先,在 Cargo.toml 中新增 crossbeam 依賴:

[dependencies]
crossbeam = "0.8.0"

然後,在程式碼中引入 crossbeam

extern crate crossbeam;

接下來,將原來的 render 呼叫替換為平行版本:

let threads = 8;
let rows_per_band = bounds.1 / threads + 1;

let bands: Vec<&mut [u8]> = pixels.chunks_mut(rows_per_band * bounds.0).collect();

crossbeam::scope(|spawner| {
    for (i, band) in bands.into_iter().enumerate() {
        let top = rows_per_band * i;
        let height = band.len() / bounds.0;
        let band_bounds = (bounds.0, height);
        let band_upper_left = pixel_to_point(bounds, (0, top), upper_left, lower_right);
        let band_lower_right = pixel_to_point(bounds, (bounds.0, top + height), upper_left, lower_right);

        spawner.spawn(move || {
            render(band, band_bounds, band_upper_left, band_lower_right);
        });
    }
}).unwrap();

內容解密:

  • let threads = 8;:決定使用 8 個執行緒進行平行處理。
  • let rows_per_band = bounds.1 / threads + 1;:計算每個條帶應該包含的行數,向上取整以確保覆寫整個影像。
  • let bands: Vec<&mut [u8]> = pixels.chunks_mut(rows_per_band * bounds.0).collect();:將 pixels 分成多個條帶,每個條帶是一個可變的切片。
  • crossbeam::scope(|spawner| { ... });:建立一個 scoped thread 環境,在其中產生多個執行緒,每個執行緒處理一個條帶。
  • spawner.spawn(move || { render(band, band_bounds, band_upper_left, band_lower_right); });:為每個條帶生成一個執行緒,呼叫 render 函式進行影像計算。

基本型別

Rust 的型別服務於多個目標:

安全性

透過檢查程式的型別,Rust 編譯器能夠排除整類別常見的錯誤。透過用型別安全的替代方案取代空指標和未經檢查的聯合,Rust 甚至能夠消除在其他語言中常見的導致當機的錯誤來源。

效率

程式設計師對於 Rust 程式如何在記憶體中表示值具有細粒度的控制,可以選擇他們知道處理器能夠有效處理的型別。程式不需要為它們不使用的通用性或靈活性付出代價。

簡潔性

Rust 在無需程式設計師在程式碼中寫出過多型別的情況下實作了上述目標。Rust 程式通常比類別似的 C++ 程式更簡潔。

Rust 被設計為使用提前編譯(ahead-of-time compilation),即在程式開始執行之前完成對整個程式到機器碼的翻譯。Rust 的型別幫助提前編譯器為程式操作的數值選擇良好的機器層級表示:其效能可以預測,並且能夠完全存取機器的能力。

平行程式設計的安全性

在前面的章節中,我們展示了一個使用 Rust 進行平行程式設計的範例。最終得到的平行程式與其他語言中的寫法並無太大差異:我們將畫素緩衝區的各部分分配給不同的處理器;讓每個處理器獨立處理其部分內容;當所有處理器完成後,呈現結果。那麼,Rust 的平行支援有什麼特別之處呢?

我們沒有展示的是所有無法用 Rust 編寫的程式。我們在本章中檢視的程式碼正確地在執行緒之間分配了緩衝區,但有很多小的變化會導致錯誤(並引入資料競爭);這些變化都不會透過 Rust 編譯器的靜態檢查。C 或 C++ 編譯器會樂於幫助您探索具有微妙資料競爭的龐大程式空間;Rust 則會提前告訴您什麼地方可能會出錯。

使用 crossbeam 進行平行計算

在 Mandelbrot 程式中,我們使用了 crossbeam::scope 函式來建立執行緒。這個函式呼叫一個閉包,並將一個可用於建立新執行緒的值作為引數傳遞給閉包。crossbeam::scope 函式會等待所有這些執行緒完成執行後再傳回。這種行為使得 Rust 能夠確保這些執行緒在它們的部分畫素超出範圍後不會存取它們,並且能夠確保當 crossbeam::scope 傳回時,影像的計算已經完成。

crossbeam::scope(|spawner| {
    for (i, band) in bands.into_iter().enumerate() {
        let top = rows_per_band * i;
        let height = band.len() / bounds.0;
        let band_bounds = (bounds.0, height);
        let band_upper_left = pixel_to_point(bounds, (0, top), upper_left, lower_right);
        let band_lower_right = pixel_to_point(bounds, (bounds.0, top + height), upper_left, lower_right);

        spawner.spawn(move || {
            render(band, band_bounds, band_upper_left, band_lower_right);
        });
    }
});

內容解密:

  • crossbeam::scope 函式用於建立一個作用域,在該作用域內可以建立執行緒。
  • spawner.spawn 方法用於建立新的執行緒,並將閉包傳遞給它以供執行。
  • move || { ... } 表示一個沒有引數的閉包,其主體是 { ... } 形式。
  • move 關鍵字表示該閉包取得了它所使用的變數的所有權。

編譯和執行 Mandelbrot 程式

我們在這個程式中使用了幾個外部 crate:num 用於複數運算;image 用於寫入 PNG 檔案;crossbeam 用於作用域執行緒建立原語。以下是完整的 Cargo.toml 檔案,包括所有依賴項:

[package]
name = "mandelbrot"
version = "0.1.0"
authors = ["You <you@example.com>"]

[dependencies]
crossbeam = "0.2.8"
image = "0.13.0"
num = "0.1.27"

有了這些設定,我們就可以建構和執行程式:

$ cargo build --release
$ time target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20

這應該會建立一個名為 mandel.png 的檔案,您可以使用系統的影像檢視程式或網頁瀏覽器檢視。如果一切順利,它應該看起來像圖 2-7。

結果與分析

透過使用 Unix 的 time 程式,我們可以看到程式執行的時間;儘管我們花費了超過六秒的處理器時間來計算影像,但實際經過的時間卻少於兩秒。您可以透過註解掉寫入影像檔案的程式碼來驗證大部分實際時間都花在寫入影像檔案上;在測試這段程式碼的筆記型電腦上,平行版本將 Mandelbrot 計算時間縮短了近四倍。我們將在第 19 章中展示如何進一步改善這一點。