Почему сборка Rust --release медленнее, чем Go?

Я пытаюсь узнать о параллелизме и параллельных вычислениях в Rust и собрал небольшой скрипт, который выполняет итерацию по вектору векторов, как будто это пиксели изображения. Поскольку сначала я пытался увидеть, насколько быстрее он становится iter по сравнению с par_iter, я добавил простой таймер, который, вероятно, не очень точен. Тем не менее, я получал сумасшедшие высокие цифры. Итак, я решил собрать аналогичный фрагмент кода на Go, который обеспечивает простой параллелизм и производительность примерно на 585% выше!

Rust тестировался с параметром --release

Я также пытался использовать собственный пул потоков, но результаты были такими же. Посмотрел, сколько потоков я использовал, и какое-то время я тоже возился с этим, но безрезультатно.

Что я делаю не так? (не обращайте внимания на определенно неэффективный способ создания вектора векторов со случайным значением)

Код ржавчины (~140 мс)

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);
}

Код перехода (~ 24 мс)

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)
}

Вы делаете огромное количество выделений кучи в Rust, реконструируя Vec<T>s, который является типом, выделенным в куче. Вы ничего не делаете на ходу. Итак, куча alloc.s и mem. копия делает на время.

Siiir 27.07.2023 02:41

Сделайте .par_iter_mut().for_each(...) в Rust и измените примитивы на месте. Также позаботьтесь о расположении кеша — поэтому используйте многомерные массивы вместо вложенных векторов или создайте одномерный вектор, но заставьте его думать, что он трехмерный, — так вы получите 1 сексуальный сегмент памяти, который ваш ЦП получит за несколько укусов. .

Siiir 27.07.2023 02:47

@Siiir Спасибо. В этом случае есть какое-либо преимущество перед 1D-массивом по сравнению с 1D-vec?

l1901 27.07.2023 02:58
Vec<T> - это просто [T;_], но в куче и с буфером, который можно уменьшить (с помощью .shrink_to_fit(). Существует определенный порог размера, я не знаю, когда лучше перемещать массив в кучу. Я думаю, что это что-то между 1 КБ-100 КБ, когда массив слишком велик и должен быть заменен на Vec<T> или Box<[T]> или Box<[T; N]>. Вы получите локальность кеша, когда ядро ​​​​ЦП сможет получить непрерывный массив памяти один раз и выполнять над ним много операций.Все массивы Rust непрерывны в памяти, даже если N-мерные.Vec<f32> будет указывать на непрерывный массив f32, а Vec<Vec<Vec<f32>>> — нет.
Siiir 27.07.2023 03:13

Подумайте о том, чтобы изменить свой код, чтобы normalized_image: Vec<[f32; 4]>. Это должно значительно ускорить код Rust и сделать сравнение честным — тип Rust [T; n] эквивалентен типу Go [n]T. Оба типа представляют собой массив n элементов типа T, где n известно во время компиляции.

Mark Saving 27.07.2023 03:52
За пределами сигналов Angular: Сигналы и пользовательские стратегии рендеринга
За пределами сигналов Angular: Сигналы и пользовательские стратегии рендеринга
TL;DR: Angular Signals может облегчить отслеживание всех выражений в представлении (Component или EmbeddedView) и планирование пользовательских...
Sniper-CSS, избегайте неиспользуемых стилей
Sniper-CSS, избегайте неиспользуемых стилей
Это краткое руководство, в котором я хочу поделиться тем, как я перешел от 212 кБ CSS к 32,1 кБ (сокращение кода на 84,91%), по-прежнему используя...
1
5
110
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

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 вы используете [4]float32 для представления четверки RBGA, а в Rust вы используете Vec<f32>. Правильным типом для производительности является [f32; 4], который представляет собой массив, содержащий ровно 4 числа с плавающей запятой. Массив с известным размером не обязательно должен выделяться в куче, в то время как Vec всегда выделяется в куче. Это резко повышает вашу производительность - на моей машине разница составляет 8 раз.

Оригинальный фрагмент:

    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 вы вычисляете нормализованные r, g, b и a, а в коде на Go вы вычисляете только _r, _g и _b. У меня на машине не установлен Go, но я полагаю, что это дает Go небольшое несправедливое преимущество перед Rust, поскольку вы выполняете меньше работы.

В-третьих, вы все еще не совсем то же самое делаете в Rust и Go. В Rust вы разбиваете исходное изображение на части и для каждой части создаете Vec<[f32; 4]>. Это означает, что у вас все еще есть куча кусков в памяти, которые вам позже нужно будет объединить в одно окончательное изображение. В Go вы разделяете исходные фрагменты и для каждого фрагмента записываете фрагмент в общий массив. Мы можем дополнительно переписать ваш код Rust, чтобы он идеально имитировал код Go. Вот как это выглядит в Rust:

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<_>>();

Это так же быстро, как вручную на моей машине.

Спасибо, что нашли время и объяснили это так подробно. Когда вы говорите: «Наконец-то, если бы вы делали это в реальной жизни…», вы имеете в виду просто отсутствие фрагментации или элегантность вашего кода?

l1901 27.07.2023 19:45

@ l1901 Первое, что вы должны попробовать, это самый простой метод, который выполняет свою работу. В реальной жизни вы бы начали делать что-то последовательно (просто с обычным .iter().map(…).collect()). Затем, если это слишком медленно, вы можете попробовать использовать искусственный шелк и заменить iter на par_iter. Если это все еще слишком медленно, вам следует тщательно протестировать возможные изменения, например, вручную. Простой код намного легче поддерживать, и делегирование району выбора того, как делать par_iter и collect параллельно, вероятно, является правильным шагом.

Mark Saving 27.07.2023 20:35

Vec<Vec<T>> обычно не рекомендуется, потому что это не очень кеширует, так как у вас Vec<Vec<Vec<T>>> ситуация еще хуже.

Процесс выделения памяти также стоил много времени.

Небольшое улучшение заключается в изменении типа на Vec<Vec<[T; N]>>, так как самый внутренний Vec<T> должен иметь фиксированный размер 4 u16 или f32. Это сократило время обработки на моем ПК с ~ 110 мс до 11 мс.

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?

l1901 27.07.2023 19:52

Другие вопросы по теме