提问人:l1901 提问时间:7/27/2023 最后编辑:l1901 更新时间:7/28/2023 访问量:186
Rust --release 构建比 Go 慢吗?
How is Rust --release build slower than Go?
问:
我正在尝试了解 Rust 的并发和并行计算,并编写了一个小脚本,该脚本迭代向量的向量,就像它是图像的像素一样。因为一开始我试图看看它比我扔一个基本的计时器快多少——这可能不是非常准确。然而,我得到了疯狂的高数字。所以,我想我会在 Go 上编写一段类似的代码,它允许轻松并发,性能提高 ~585%!iter
par_iter
Rust 使用 --release 进行了测试
我也尝试使用本机线程池,但结果是一样的。看看我使用了多少线程,有一段时间我也在搞砸它,但无济于事。
我做错了什么? (不要介意创建随机值填充向量的向量的绝对不高性能的方法)
Rust 代码 (~140ms)
use rand::Rng;
use std::time::Instant;
use rayon::prelude::*;
fn normalise(value: u16, min: u16, max: u16) -> f32 {
(value - min) as f32 / (max - min) as f32
}
fn main() {
let pixel_size = 9_000_000;
let fake_image: Vec<Vec<u16>> = (0..pixel_size).map(|_| {
(0..4).map(|_| {
rand::thread_rng().gen_range(0..=u16::MAX)
}).collect()
}).collect();
// Time starts now.
let now = Instant::now();
let chunk_size = 300_000;
let _normalised_image: Vec<Vec<Vec<f32>>> = fake_image.par_chunks(chunk_size).map(|chunk| {
let normalised_chunk: Vec<Vec<f32>> = chunk.iter().map(|i| {
let r = normalise(i[0], 0, u16::MAX);
let g = normalise(i[1], 0, u16::MAX);
let b = normalise(i[2], 0, u16::MAX);
let a = normalise(i[3], 0, u16::MAX);
vec![r, g, b, a]
}).collect();
normalised_chunk
}).collect();
// Timer ends.
let elapsed = now.elapsed();
println!("Time elapsed: {:.2?}", elapsed);
}
Go 代码 (~24ms)
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
func normalise(value uint16, min uint16, max uint16) float32 {
return float32(value-min) / float32(max-min)
}
func main() {
const pixelSize = 9000000
var fakeImage [][]uint16
// Create a new random number generator
src := rand.NewSource(time.Now().UnixNano())
rng := rand.New(src)
for i := 0; i < pixelSize; i++ {
var pixel []uint16
for j := 0; j < 4; j++ {
pixel = append(pixel, uint16(rng.Intn(1<<16)))
}
fakeImage = append(fakeImage, pixel)
}
normalised_image := make([][4]float32, pixelSize)
var wg sync.WaitGroup
// Time starts now
now := time.Now()
chunkSize := 300_000
numChunks := pixelSize / chunkSize
if pixelSize%chunkSize != 0 {
numChunks++
}
for i := 0; i < numChunks; i++ {
wg.Add(1)
go func(i int) {
// Loop through the pixels in the chunk
for j := i * chunkSize; j < (i+1)*chunkSize && j < pixelSize; j++ {
// Normalise the pixel values
_r := normalise(fakeImage[j][0], 0, ^uint16(0))
_g := normalise(fakeImage[j][1], 0, ^uint16(0))
_b := normalise(fakeImage[j][2], 0, ^uint16(0))
_a := normalise(fakeImage[j][3], 0, ^uint16(0))
// Set the pixel values
normalised_image[j][0] = _r
normalised_image[j][1] = _g
normalised_image[j][2] = _b
normalised_image[j][3] = _a
}
wg.Done()
}(i)
}
wg.Wait()
elapsed := time.Since(now)
fmt.Println("Time taken:", elapsed)
}
答:
use rand::Rng;
use std::time::Instant;
use rayon::prelude::*;
fn normalise(value: u16, min: u16, max: u16) -> f32 {
(value - min) as f32 / (max - min) as f32
}
type PixelU16 = (u16, u16, u16, u16);
type PixelF32 = (f32, f32, f32, f32);
fn main() {
let pixel_size = 9_000_000;
let fake_image: Vec<PixelU16> = (0..pixel_size).map(|_| {
let mut rng =
rand::thread_rng();
(rng.gen_range(0..=u16::MAX), rng.gen_range(0..=u16::MAX), rng.gen_range(0..=u16::MAX), rng.gen_range(0..=u16::MAX))
}).collect();
// Time starts now.
let now = Instant::now();
let chunk_size = 300_000;
let _normalised_image: Vec<Vec<PixelF32>> = fake_image.par_chunks(chunk_size).map(|chunk| {
let normalised_chunk: Vec<PixelF32> = chunk.iter().map(|i| {
let r = normalise(i.0, 0, u16::MAX);
let g = normalise(i.1, 0, u16::MAX);
let b = normalise(i.2, 0, u16::MAX);
let a = normalise(i.3, 0, u16::MAX);
(r, g, b, a)
}).collect::<Vec<_>>();
normalised_chunk
}).collect();
// Timer ends.
let elapsed = now.elapsed();
println!("Time elapsed: {:.2?}", elapsed);
}
我已经使用数组切换到元组,该解决方案已经比您在我的机器上提供的解决方案快 10 倍。甚至可以通过减少堆分配量来削减和使用一个或某个通道来提高速度。Vec
Arc<Mutex<Vec<Pixel>>>
mpsc
加速 Rust 代码最重要的初始更改是使用正确的类型。在 Go 中,你使用 a 来表示 RBGA 四元组,而在 Rust 中,你使用 .用于提高性能的正确类型是 ,这是一个已知正好包含 4 个浮点数的数组。具有已知大小的数组不需要进行堆分配,而 a 始终是堆分配的。这大大提高了您的性能 - 在我的机器上,这是 8 倍的差异。[4]float32
Vec<f32>
[f32; 4]
Vec
原始片段:
let fake_image: Vec<Vec<u16>> = (0..pixel_size).map(|_| {
(0..4).map(|_| {
rand::thread_rng().gen_range(0..=u16::MAX)
}).collect()
}).collect();
...
let _normalised_image: Vec<Vec<Vec<f32>>> = fake_image.par_chunks(chunk_size).map(|chunk| {
let normalised_chunk: Vec<Vec<f32>> = chunk.iter().map(|i| {
let r = normalise(i[0], 0, u16::MAX);
let g = normalise(i[1], 0, u16::MAX);
let b = normalise(i[2], 0, u16::MAX);
let a = normalise(i[3], 0, u16::MAX);
vec![r, g, b, a]
}).collect();
normalised_chunk
}).collect();
新片段:
let fake_image: Vec<[u16; 4]> = (0..pixel_size).map(|_| {
let mut result: [u16; 4] = Default::default();
result.fill_with(|| rand::thread_rng().gen_range(0..=u16::MAX));
result
}).collect();
...
let _normalised_image: Vec<Vec<[f32; 4]>> = fake_image.par_chunks(chunk_size).map(|chunk| {
let normalised_chunk: Vec<[f32; 4]> = chunk.iter().map(|i| {
let r = normalise(i[0], 0, u16::MAX);
let g = normalise(i[1], 0, u16::MAX);
let b = normalise(i[2], 0, u16::MAX);
let a = normalise(i[3], 0, u16::MAX);
[r, g, b, a]
}).collect();
normalised_chunk
}).collect();
在我的机器上,这导致了大约 7.7 倍的加速,使 Rust 和 Go 大致持平。为每个四元组进行堆分配的开销大大减慢了 Rust 的速度,并淹没了其他一切;消除这种情况使 Rust 和 Go 站得更稳脚跟。
其次,您的 Go 代码中有一个轻微的错误。在 Rust 代码中,你计算一个规范化的 、 、 和 ,而在你的 Go 代码中,你只计算 、 和 。我的机器上没有安装 Go,但我想这给了 Go 比 Rust 略有不公平的优势,因为你做的工作更少。r
g
b
a
_r
_g
_b
第三,你在 Rust 和 Go 中仍然没有做同样的事情。在 Rust 中,将原始图像拆分为多个块,并为每个块生成一个 .这意味着你仍然有一堆块在内存中,你以后必须将它们组合成一个最终图像。在 Go 中,拆分原始块,并为每个块将块写入一个公共数组。我们可以进一步重写你的 Rust 代码,以完美地模仿 Go 代码。这是 Rust 中的样子:Vec<[f32; 4]>
let _normalized_image: Vec<[f32; 4]> = {
let mut destination = vec![[0 as f32; 4]; pixel_size];
fake_image
.par_chunks(chunk_size)
// The "zip" function allows us to iterate over a chunk of the input
// array together with a chunk of the destination array.
.zip(destination.par_chunks_mut(chunk_size))
.for_each(|(i_chunk, d_chunk)| {
// Sanity check: the chunks should be of equal length.
assert!(i_chunk.len() == d_chunk.len());
for (i, d) in i_chunk.iter().zip(d_chunk) {
let r = normalise(i[0], 0, u16::MAX);
let g = normalise(i[1], 0, u16::MAX);
let b = normalise(i[2], 0, u16::MAX);
let a = normalise(i[3], 0, u16::MAX);
*d = [r, g, b, a];
// Alternately, we could do the following loop:
// for j in 0..4 {
// d[j] = normalise(i[j], 0, u16::MAX);
// }
}
});
destination
};
现在你的 Rust 代码和你的 Go 代码确实在做同样的事情。我怀疑你会发现 Rust 代码稍微快一些。
最后,如果你在现实生活中这样做,你应该尝试的第一件事是使用如下:map
let _normalized_image = fake_image.par_iter().map(|&[r, b, g, a]| {
[ normalise(r, 0, u16::MAX),
normalise(b, 0, u16::MAX),
normalise(g, 0, u16::MAX),
normalise(a, 0, u16::MAX),
]
}).collect::<Vec<_>>();
这与在我的机器上手动分块一样快。
评论
.iter().map(…).collect()
iter
par_iter
par_iter
collect
Vec<Vec<T>>
通常不推荐,因为它对缓存不是很友好,因为你的情况更糟。Vec<Vec<Vec<T>>>
内存分配过程也花费了大量时间。
稍作改进是将类型更改为 ,因为最内层应该是 4 u16 或 f32 的固定大小。这将我的 PC 上的处理时间从 ~110 毫秒减少到 11 毫秒。Vec<Vec<[T; N]>>
Vec<T>
fn rev1() {
let pixel_size = 9_000_000;
let chunk_size = 300_000;
let fake_image: Vec<[u16; 4]> = (0..pixel_size)
.map(|_| {
core::array::from_fn(|_| rand::thread_rng().gen_range(0..=u16::MAX))
})
.collect();
// Time starts now.
let now = Instant::now();
let _normalized_image: Vec<Vec<[f32; 4]>> = fake_image
.par_chunks(chunk_size)
.map(|chunk| {
chunk
.iter()
.map(|rgba: &[u16; 4]| rgba.map(|v| normalise(v, 0, u16::MAX)))
.collect()
})
.collect();
// Timer ends.
let elapsed = now.elapsed();
println!("Time elapsed (r1): {:.2?}", elapsed);
}
但是,这仍然需要大量的分配和副本。如果不需要新的载体,就地突变可以更快。~5毫秒
pub fn rev2() {
let pixel_size = 9_000_000;
let chunk_size = 300_000;
let mut fake_image: Vec<Vec<[f32; 4]>> = (0..pixel_size / chunk_size)
.map(|_| {
(0..chunk_size)
.map(|_| {
core::array::from_fn(|_| {
rand::thread_rng().gen_range(0..=u16::MAX) as f32
})
})
.collect()
})
.collect();
// Time starts now.
let now = Instant::now();
fake_image.par_iter_mut().for_each(|chunk| {
chunk.iter_mut().for_each(|rgba: &mut [f32; 4]| {
rgba.iter_mut().for_each(|v: &mut _| {
*v = normalise_f32(*v, 0f32, u16::MAX as f32)
})
})
});
// Timer ends.
let elapsed = now.elapsed();
println!("Time elapsed (r2): {:.2?}", elapsed);
}
在这里,它仍然不理想,而在这种特定情况下,扁平化它不会产生显着的性能改进。访问此嵌套数组结构中的元素将比平面数组慢。Vec<Vec<T>>
/// Create a new flat Vec from fake_image
pub fn rev3() {
let pixel_size = 9_000_000;
let _chunk_size = 300_000;
let fake_image: Vec<[u16; 4]> = (0..pixel_size)
.map(|_| {
core::array::from_fn(|_| rand::thread_rng().gen_range(0..=u16::MAX))
})
.collect();
// Time starts now.
let now = Instant::now();
let _normalized_image: Vec<[f32; 4]> = fake_image
.par_iter()
.map(|rgba: &[u16; 4]| rgba.map(|v| normalise(v, 0, u16::MAX)))
.collect();
// Timer ends.
let elapsed = now.elapsed();
println!("Time elapsed (r3): {:.2?}", elapsed);
}
/// In place mutation of a flat Vec
pub fn rev4() {
let pixel_size = 9_000_000;
let _chunk_size = 300_000;
let mut fake_image: Vec<[f32; 4]> = (0..pixel_size)
.map(|_| {
core::array::from_fn(|_| {
rand::thread_rng().gen_range(0..=u16::MAX) as f32
})
})
.collect();
// Time starts now.
let now = Instant::now();
fake_image.par_iter_mut().for_each(|rgba: &mut [f32; 4]| {
rgba.iter_mut()
.for_each(|v: &mut _| *v = normalise_f32(*v, 0f32, u16::MAX as f32))
});
// Timer ends.
let elapsed = now.elapsed();
println!("Time elapsed (r4): {:.2?}", elapsed);
}
评论
Vec<T>
.par_iter_mut().for_each(...)
Vec<T>
只是一个 but 在堆上,并且具有可以收缩的缓冲区(使用 .有一个一定的大小阈值,我不知道什么时候将数组移动到堆中更好。我认为当数组很大时,应该在 1KB-100KB 之间,应该用 Vec<T> 或 Box<[T]> 或 Box<[T;N]>。当 CPU 内核能够获取一次连续的内存数组并对其执行许多操作时,您将获得缓存位置。所有 Rust 数组在内存中都是连续的,即使是 N 维的。Vec<f32> 将指向 f32 的连续数组,但 Vec<Vec<Vec<f32>>> 不是。[T;_]
.shrink_to_fit()
normalized_image: Vec<[f32; 4]>
[T; n]
[n]T
n
T
n