Skip to content

Rust 和 Go 在图像处理上的简单对比 #23

Open
@yangwenmai

Description

@yangwenmai

背景

大家都说 Rust 比较擅长系统底层,我猜想图像处理还是很底层的。

至少比较好用的都是 C 语言实现的。

imagemagick

libpng ? 也是 C 实现的。

那我们是不是可以来测试一下 Rust 和 Go 在图像处理上的表现呢?首先从 decode 开始。

Rust decode 一个图片文件

for _ in 0..10 {
    let timer = Instant::now();
    let tiny = image::open("examples/scaleup/out0.png").unwrap();
    println!("cost: {}", Elapsed::from(&timer));
}

耗时:

Decode in ~1.73 s

Rust 指定 Release 模式下运行 Decode 耗时 :

Decode in ~21 ms

image

还可以指定 opt-level3

Go decode 一个图片文件

startTime := time.Now()
data, err := ioutil.ReadFile("out0.png")
if err != nil {
 panic(err)
}
rd := bytes.NewReader(data)
image.Decode(rd)
fmt.Println("cost:", time.Now().Sub(startTime))

耗时:695.732µs

当时我就震惊了!!!

注意看上面的代码 image.Decode(rd) 这里有 error 返回,但是这里测试代码没有捕获。

其实它会报错:

panic: image: unknown format

代码修改为:

for i := 0;i < 10; i++ {
   startTime := time.Now()
   
   data, err := ioutil.ReadFile("rust.png")
   if err != nil {
      panic(err)
   }
   rd := bytes.NewReader(data)
   _,_,err = image.Decode(rd)
   if err != nil {
      panic(err)
   }
   
   fmt.Println("耗时:", time.Now().Sub(startTime))
}

使用 png 解析就正常了:

for i := 0;i < 10; i++ {
   startTime := time.Now()
   
   data, err := ioutil.ReadFile("rust.png")
   if err != nil {
      panic(err)
   }
   rd := bytes.NewReader(data)
   _, err = png.Decode(rd)
   if err != nil {
      panic(err)
   }
   
   fmt.Println("耗时:", time.Now().Sub(startTime))
}

执行耗时:
耗时: ~15.914074ms

Rust 和 Go 在对 png 图片进行 decode 时,两者的耗时差别并不大。

当我们将图片更换为 jpeg 后,他们的对比如下:

Rust(RELEASE模式下):

Decode in 3 ms

Go:

耗时: ~5.472894ms

对于 jpeg 的图片,Rust decode 要稍稍优于 Go 的 jpeg decode。


分析讨论过程

通过看 image 的源码发现 png 这个库 next frame 这个方法比较慢。
go版本一次性读整个图,png要一行一行的读,且每行都要一次内存拷贝
为了更高的抽象层级,有非常多细碎的内存拷贝
找到原因了:每行会创建一个Vec,一次Vec创建的时间在几十微秒左右,一个几百行的图片,主要会花在内存分配上

(准确说,单纯创建Vec不会发生堆内存分配,等价于一个栈上变量,代价可以忽略,但随后会对其写入,此时就会导致堆内存分配)

其实怎么存都有问题,抛开内存分配的问题,flatten到一维,行序,列序在处理的时候都对cache不友好

分析2:



主要不是内存分配的问题,其实在初始化的时候已经通过宏得到了图片大小,一次性分配好了。
主要是内存copy的问题,那里还注释了 TODO 待优化。

内存copy,还有下面那一行into转换,内存会重新分配吧,作者打算留给有缘人优化了。

Rust不保证代码的性能。

初学者用rust比较难写出高性能的程序吧,但是用go可以好一点。
应该是 初学者用rust比较难写出程序

写不好rust是我不行,不是rust不行。
很多人误以为,用rust写了代码就性能好了

其实我的印象里,内存拷贝的成本应该比内存分配低?

不过至少可以确定,image 这个库的速度确实是慢😂

我还测试了一下 jpeg 的解码,发现速度也一样糟糕

没法复用,他api设计的时候就断了复用的念头了

关键是后面解码的时候remalloc
读Row是个公开api,返回的是字节序列引用

作者还是有考虑的,可能处理时候有点问题,还没细看


Rust 还是一个新手,所以源代码和实现逻辑还得仔细研究研究再来理解大家的讨论了。

其他

环境:

MacBookPro 2017
3.1 GHz Intel Core i7
16 GB 2133 MHz LPDDR3

rustc 1.36.0-nightly (33fe1131c 2019-04-20)
cargo 1.36.0-nightly (b6581d383 2019-04-16)
Go 1.12.4

Rust 画一个○

使用 image-rs/imageproc 在一个 1000x1000 的画板上画一个 500x500 的圆:

//! An example using the drawing functions. Writes to the user-provided target file.

use std::env;
use std::path::Path;
use std::fmt;
use std::time::{Duration, Instant};
use image::{Rgb, RgbImage};
use imageproc::rect::Rect;
use imageproc::drawing::{
    draw_cross_mut,
    draw_line_segment_mut,
    draw_hollow_rect_mut,
    draw_filled_rect_mut,
    draw_hollow_circle_mut,
    draw_filled_circle_mut
};
struct Elapsed(Duration);
impl Elapsed {
    fn from(start: &Instant) -> Self {
        Elapsed(start.elapsed())
    }
}

impl fmt::Display for Elapsed {
    fn fmt(&self, out: &mut fmt::Formatter) -> Result<(), fmt::Error> {
        match (self.0.as_secs(), self.0.subsec_nanos()) {
            (0, n) if n < 1000 => write!(out, "{} ns", n),
            (0, n) if n < 1000_000 => write!(out, "{} µs", n / 1000),
            (0, n) => write!(out, "{} ms", n / 1000_000),
            (s, n) if s < 10 => write!(out, "{}.{:02} s", s, n / 10_000_000),
            (s, _) => write!(out, "{} s", s),
        }
    }
}

fn main() {
    let arg = if env::args().count() == 2 {
            env::args().nth(1).unwrap()
        } else {
            panic!("Please enter a target file path")
        };
    let timer = Instant::now();
    let path = Path::new(&arg);
    let white = Rgb([255u8, 255u8, 255u8]);

    let mut image = RgbImage::new(1000, 1000);
    // Draw a filled circle within bounds
    draw_filled_circle_mut(&mut image, (500, 500), 400, white);
    image.save(path).unwrap();
    println!("draw in {}", Elapsed::from(&timer));
}

Debug:
Output: draw in 1.22s

Release:
Output:draw in 20ms

image

注意:此样例代码,必须在 image-rs/imageproc/examples/drawing.rs 中运行。单独运行会报错:

error[E0277]: the trait bound `image::buffer::ImageBuffer<image::color::Rgb<u8>, std::vec::Vec<u8>>: image::image::GenericImage` is not satisfied
  --> src/main.rs:48:5
   |
48 |     draw_filled_circle_mut(&mut image, (500, 500), 400, white);
   |     ^^^^^^^^^^^^^^^^^^^^^^ the trait `image::image::GenericImage` is not implemented for `image::buffer::ImageBuffer<image::color::Rgb<u8>, std::vec::Vec<u8>>`
   |
   = note: required by `imageproc::drawing::conics::draw_filled_circle_mut`

Go 画一个○

package main

import (
	"bytes"
	"fmt"
	"github.com/fogleman/gg"
	"image"
	"io"
	"io/ioutil"
	"time"
)

func main() {
	startTime := time.Now()
	dc := gg.NewContext(1000, 1000)
	dc.DrawCircle(500, 500, 400)
	dc.SetRGB(0, 0, 0)
	dc.Fill()

	dc.SavePNG("out.png")

	// 99.617772ms ~ 108.1321ms
	fmt.Println("耗时:", time.Now().Sub(startTime))
}

耗时:99ms ~ 108ms 左右。

image

简单对比 Rust 比 Go 快 5 倍。(这个还是非常值得期待的,但是两者图像不太一样,所以还需要修补修补)
以上代码:https://github.com/developer-learning/learning-rust/tree/master/practices/image

翻转我的头像文件(不生成文件)

使用 github.com/disintegration/imaging 库:

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/disintegration/imaging"
)

func main() {
	for i := 0; i < 10; i++ {
		startTime := time.Now()
		img, err := imaging.Open("avatar-origin.jpg")
		if err != nil {
			log.Fatalln(err)
			return
		}
		imaging.FlipH(img)
		fmt.Println("cost:", time.Now().Sub(startTime))
	}
}

使用 Rust 的 image crate 源代码:

extern crate image;

use image::{FilterType, PNG};
use std::fmt;
use std::fs::File;
use std::time::{Duration, Instant};

struct Elapsed(Duration);

impl Elapsed {
    fn from(start: &Instant) -> Self {
        Elapsed(start.elapsed())
    }
}

impl fmt::Display for Elapsed {
    fn fmt(&self, out: &mut fmt::Formatter) -> Result<(), fmt::Error> {
        match (self.0.as_secs(), self.0.subsec_nanos()) {
            (0, n) if n < 1000 => write!(out, "{} ns", n),
            (0, n) if n < 1000_000 => write!(out, "{} µs", n / 1000),
            (0, n) => write!(out, "{} ms", n / 1000_000),
            (s, n) if s < 10 => write!(out, "{}.{:02} s", s, n / 10_000_000),
            (s, _) => write!(out, "{} s", s),
        }
    }
}

fn main() {
    for _ in 0..10 {
        let timer = Instant::now();
        let tiny = image::open("examples/scaleup/avatar-origin.jpg").unwrap();
        tiny.fliph();
        println!("Decode in {}", Elapsed::from(&timer));
    }
}

执行10次,耗时:

非 release 模式下执行:

Cost in 550 ms

Rust decode && flip h 比 Go github.com/disintegration/imaging decode && flip h 要快 8 ms。

Go :

cost: 26.731724ms

Rust release 模式下:

Cost 17 ms

Go decode && flip h && save 比 Rust decode && flip h && save 要快 8 ms。

Go decode && flip h && save cost:

cost: 75.040754ms

Rust decode && flip h && save cost:

Cost in 81 ms

去掉 flip ,纯 image decode 然后再 save,则 Rust 比 Go 慢 10ms:

Go decode && save:

cost: 68.575711ms

Rust decode && save:

Cost in 77 ms

一探究竟

上面的代码中 Go decode && flip h && save 比 Rust 快 8-10 ms,我们也已经知道差距是在 save。
所以我们研究一下 Go 和 Rust 的 save 部分代码。

Go 代码:

var defaultEncodeConfig = encodeConfig{
	jpegQuality:         95,
	gifNumColors:        256,
	gifQuantizer:        nil,
	gifDrawer:           nil,
	pngCompressionLevel: png.DefaultCompression,
}

很明显 Go save 的时间比较小是因为 jpegQuality 默认是 95 ,所以指定 jpegQuality 为 100: err = imaging.Save(img, "newavatar-origin-flip-h.jpg", imaging.JPEGQuality(100)) 执行:

cost: 89.767576ms

Go 整体执行时间比 Rust 多 3-5ms。

缩放

Go:

img = imaging.Fit(img, 200, 200, imaging.Lanczos)

Rust:

let mut d = tiny.resize(200, 200, FilterType::Lanczos3);

旋转

Go:

img = imaging.Rotate(img, -90, color.RGBA{0, 0, 0, 0})

Rust:

let mut d = tiny.rotate90();

参考资料

  1. https://github.com/image-rs/image
  2. https://github.com/golang/go#image
  3. Removing unnecessary copying in next_raw_interlaced_row image-rs/image-png#61
  4. Rust 和 Go 在图像处理上的性能之争
  5. Drawing a circle, but cost over 1 second, it's normal? #324

引用 wish:

语言层面 micro benchmark 还是挺多的,这些衡量语言本身性能的好坏应该足够了。至于库的话,生态也是语言的一部分,是工程中需要参考的因素。比如我觉得衡量 grpc go 性能和 grpc c core 性能差距得出语言性能差距,本身意义不大,但如果要用 grpc,那么是个很好的参考了。

我个人非常认同,语言好坏并不是一概而论的,有时候你得考虑更多方面,比方说:工程化、生态等。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions