【发布时间】:2021-05-20 07:25:17
【问题描述】:
我需要在一个(透明的)PNG 文件上绘制一个带有居中 Text 的 Squares 二维网格。 瓷砖需要有足够大的分辨率,以便文本不会被像素化太多。
出于测试目的,我创建了一个 2048x2048px 32 位(透明)PNG 图像,其中包含 128x128px 的图块,例如:
问题是我需要以合理的性能做到这一点。到目前为止,我尝试过的所有方法都需要超过 100 毫秒才能完成,而我需要将其设置为最大 WebAssembly (但即使你知道如何使用 posix 线程等来做到这一点。我很乐意接受这也是一个起点)。
Net5 实现
using System.Diagnostics;
using System;
using System.Drawing;
namespace ImageGeneratorBenchmark
{
class Program
{
static int rowColCount = 16;
static int tileSize = 128;
static void Main(string[] args)
{
var watch = Stopwatch.StartNew();
Bitmap bitmap = new Bitmap(rowColCount * tileSize, rowColCount * tileSize);
Graphics graphics = Graphics.FromImage(bitmap);
Brush[] usedBrushes = { Brushes.Blue, Brushes.Red, Brushes.Green, Brushes.Orange, Brushes.Yellow };
int totalCount = rowColCount * rowColCount;
Random random = new Random();
StringFormat format = new StringFormat();
format.LineAlignment = StringAlignment.Center;
format.Alignment = StringAlignment.Center;
for (int i = 0; i < totalCount; i++)
{
int x = i % rowColCount * tileSize;
int y = i / rowColCount * tileSize;
graphics.FillRectangle(usedBrushes[random.Next(0, usedBrushes.Length)], x, y, tileSize, tileSize);
graphics.DrawString(i.ToString(), SystemFonts.DefaultFont, Brushes.Black, x + tileSize / 2, y + tileSize / 2, format);
}
bitmap.Save("Test.png");
watch.Stop();
Console.WriteLine($"Output took {watch.ElapsedMilliseconds} ms.");
}
}
}
这在我的机器上大约需要 115 毫秒。我在这里使用System.Drawing.Common nuget。
保存位图大约需要 55ms,在循环中绘制到图形对象也大约需要 60ms,而 40ms 可以归因于绘制文本。
Rust 实现
use std::path::Path;
use std::time::Instant;
use image::{Rgba, RgbaImage};
use imageproc::{drawing::{draw_text_mut, draw_filled_rect_mut, text_size}, rect::Rect};
use rusttype::{Font, Scale};
use rand::Rng;
#[derive(Default)]
struct TextureAtlas {
segment_size: u16, // The side length of the tile
row_col_count: u8, // The amount of tiles in horizontal and vertical direction
current_segment: u32 // Points to the next segment, that will be used
}
fn main() {
let before = Instant::now();
let mut atlas = TextureAtlas {
segment_size: 128,
row_col_count: 16,
..Default::default()
};
let path = Path::new("test.png");
let colors = vec![Rgba([132u8, 132u8, 132u8, 255u8]), Rgba([132u8, 255u8, 32u8, 120u8]), Rgba([200u8, 255u8, 132u8, 255u8]), Rgba([255u8, 0u8, 0u8, 255u8])];
let mut image = RgbaImage::new(2048, 2048);
let font = Vec::from(include_bytes!("../assets/DejaVuSans.ttf") as &[u8]);
let font = Font::try_from_vec(font).unwrap();
let font_size = 40.0;
let scale = Scale {
x: font_size,
y: font_size,
};
// Draw random color rects for benchmarking
for i in 0..256 {
let rand_num = rand::thread_rng().gen_range(0..colors.len());
draw_filled_rect_mut(
&mut image,
Rect::at((atlas.current_segment as i32 % atlas.row_col_count as i32) * atlas.segment_size as i32, (atlas.current_segment as i32 / atlas.row_col_count as i32) * atlas.segment_size as i32)
.of_size(atlas.segment_size.into(), atlas.segment_size.into()),
colors[rand_num]);
let number = i.to_string();
//let text = &number[..];
let text = number.as_str(); // Somehow this conversion takes ~15ms here for 255 iterations, whereas it should normally only be less than 1us
let (w, h) = text_size(scale, &font, text);
draw_text_mut(
&mut image,
Rgba([0u8, 0u8, 0u8, 255u8]),
(atlas.current_segment % atlas.row_col_count as u32) * atlas.segment_size as u32 + atlas.segment_size as u32 / 2 - w as u32 / 2,
(atlas.current_segment / atlas.row_col_count as u32) * atlas.segment_size as u32 + atlas.segment_size as u32 / 2 - h as u32 / 2,
scale,
&font,
text);
atlas.current_segment += 1;
}
image.save(path).unwrap();
println!("Output took {:?}", before.elapsed());
}
对于 Rust,我使用的是 imageproc crate。以前我使用piet-common crate,但输出耗时超过 300 毫秒。使用imageproc crate,我在发布模式下得到了大约 110ms,这与 C# 版本相当,但我认为它在使用 webassembly 时会表现得更好。
当我使用静态字符串而不是从循环中转换数字时(见评论),我得到了低于 100 毫秒的执行时间。 Rust 绘制到图像只需要大约 30 毫秒,但保存需要 80 毫秒。
C++ 实现
#include <iostream>
#include <cstdlib>
#define cimg_display 0
#define cimg_use_png
#include "CImg.h"
#include <chrono>
#include <string>
using namespace cimg_library;
using namespace std;
/* Generate random numbers in an inclusive range. */
int random(int min, int max)
{
static bool first = true;
if (first)
{
srand(time(NULL));
first = false;
}
return min + rand() % ((max + 1) - min);
}
int main() {
auto t1 = std::chrono::high_resolution_clock::now();
static int tile_size = 128;
static int row_col_count = 16;
// Create 2048x2048px image.
CImg<unsigned char> image(tile_size*row_col_count, tile_size*row_col_count, 1, 3);
// Make some colours.
unsigned char cyan[] = { 0, 255, 255 };
unsigned char black[] = { 0, 0, 0 };
unsigned char yellow[] = { 255, 255, 0 };
unsigned char red[] = { 255, 0, 0 };
unsigned char green[] = { 0, 255, 0 };
unsigned char orange[] = { 255, 165, 0 };
unsigned char colors [] = { // This is terrible, but I don't now C++ very well.
cyan[0], cyan[1], cyan[2],
yellow[0], yellow[1], yellow[2],
red[0], red[1], red[2],
green[0], green[1], green[2],
orange[0], orange[1], orange[2],
};
int total_count = row_col_count * row_col_count;
for (size_t i = 0; i < total_count; i++)
{
int x = i % row_col_count * tile_size;
int y = i / row_col_count * tile_size;
int random_color_index = random(0, 4);
unsigned char current_color [] = { colors[random_color_index * 3], colors[random_color_index * 3 + 1], colors[random_color_index * 3 + 2] };
image.draw_rectangle(x, y, x + tile_size, y + tile_size, current_color, 1.0); // Force use of transparency. -> Does not work. Always outputs 24bit PNGs.
auto s = std::to_string(i);
CImg<unsigned char> imgtext;
unsigned char color = 1;
imgtext.draw_text(0, 0, s.c_str(), &color, 0, 1, 40); // Measure the text by drawing to an empty instance, so that the bounding box will be set automatically.
image.draw_text(x + tile_size / 2 - imgtext.width() / 2, y + tile_size / 2 - imgtext.height() / 2, s.c_str(), black, 0, 1, 40);
}
// Save result image as PNG (libpng and GraphicsMagick are required).
image.save_png("Test.png");
auto t2 = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();
std::cout << "Output took " << duration << "ms.";
getchar();
}
我还使用CImg 在 C++ 中重新实现了相同的程序。对于 .png 输出,libpng 和 GraphicsMagick 也是必需的。我对 C++ 不是很流利,我什至没有费心去优化,因为在发布模式下保存操作大约需要 200 毫秒,而当前非常未优化的整个图像生成只需要 30 毫秒。所以这个解决方案也远未达到我的目标。
我现在在哪里
我为什么要这样做以及为什么它让我如此困扰
cmets 要求我提供更多背景信息。我知道这个问题有点臃肿,但如果你有兴趣继续阅读......
所以基本上我需要为 .gltf 文件构建一个 Texture Atlas。我需要从数据生成一个 .gltf 文件,并且 .gltf 文件中的图元也将根据输入数据分配一个纹理。为了优化少量的绘制调用,我将尽可能多的几何体放入一个图元中,然后使用纹理坐标将纹理映射到模型。现在 GPU 具有纹理可以具有的最大尺寸。我将使用 2048x2048 像素,因为大多数设备至少支持该像素。这意味着,如果我有超过 256 个对象,我需要向 .gltf 添加一个新基元并生成另一个纹理图集。在某些情况下,一个纹理图集可能就足够了,而在其他情况下,我需要多达 15-20 个。
纹理将具有(半)透明背景,可能是文本,也可能是一些线条/影线或简单符号,可以用路径绘制。
我已经在 Rust 中设置了整个系统,并且 .gltf 生成非常有效:我可以在大约 10 毫秒内生成 54000 个顶点(例如=1500 个框),这是一种常见的情况。现在为此我需要生成 6 个纹理图集,这在多核系统上并不是一个真正的问题(7 个线程,一个用于 .gltf,六个用于纹理)。问题是生成一个需要大约 100 毫秒(或现在 55 毫秒),这使得整个过程慢了 5 倍以上。
不幸的是,它变得更糟,因为另一个常见的情况是 15000 个对象。生成顶点(实际上还有很多自定义属性)和组装 .gltf 仍然只需要 96 毫秒(540000 顶点/20MB .gltf),但那时我需要生成 59 个纹理图集。我正在开发一个 8 核系统,所以那时我不可能并行运行它们,我必须为每个线程生成约 9 个图集(这意味着 55ms*9 = 495ms)所以这又是 5倍,实际上造成了相当明显的滞后。实际上,目前它需要超过 2.5 秒,因为我已经更新为使用更快的代码,而且似乎还有额外的减速。
我需要做什么
我知道写出 4194304 个 32 位像素需要一些时间。但据我所见,因为我只写入图像的不同部分(例如只写入上部瓷砖等),所以应该可以构建一个使用多个线程执行此操作的程序。这就是我想尝试的,并且我会就如何让我的 Rust 程序运行得更快提出任何提示。
如果有帮助,我也愿意用 C 或任何其他语言重写它,它可以编译为 wasm,并且可以通过 Rust 的 FFI 调用。因此,如果您对性能更高的库有任何建议,我也会非常感谢。
编辑
更新 1: 我从 cmets 对 C# 版本进行了所有建议的改进。感谢他们所有人。它现在是 115 毫秒,几乎和 Rust 版本一样快,这让我相信我在那里遇到了死胡同,我真的需要找到一种方法来并行化它,以便做出重大的进一步改进。 .
更新 2:感谢@pinkfloydx33,在使用dotnet publish -p:PublishReadyToRun=true --runtime win10-x64 --configuration Release 发布二进制文件后,我能够在大约 60 毫秒(包括第一次运行)内运行二进制文件。
与此同时,我自己也尝试了其他方法,即 Python 使用 Pillow (~400ms)、C# 和 Rust 都使用 Skia (~314ms 和 ~260ms),我还使用 @987654328 在 C++ 中重新实现了程序@(和 libpng 以及 GraphicsMagick)。
【问题讨论】:
-
并不是说它会有多大帮助,但您可以在循环外缓存
Math.Pow()的计算,而不是每次都重新计算。i.ToString可能缓存了?标准做法:避免创建new Random,尤其是在每次迭代中。在循环之外或作为静态字段创建它。我怀疑它会比 GDI/native 更快,但您也许可以LockBits并手动在矩形中着色并仅将 native 用于文本 -
您可以尝试使用居中的字符串格式和矩形替换测量部分
-
你也可以试试这个:stackoverflow.com/a/26498/491907 将文本垂直和水平居中而不是每次都重新计算
-
您的原始解决方案在我的机器上运行时间约为 380 毫秒;只需使用上面的建议,我就可以将它降低到 ~70ms(在我的机器上)。您可能想尝试一下,看看效果如何(看起来您的机器可能比我的好)。我尝试使用 LockBits+unsafe 绘制矩形,然后使用 GDI 进行测试,但效果更糟
-
是的,您必须多次运行它才能让 JIT 启动。首先在循环中运行每个方法大约 15-20 次,然后丢弃并再次运行您的方法(穷人的基准测试)。还要确保您正在运行 RELEASE 构建,而不是在 Visual Studio 中执行它(从命令行运行)
标签: c# multithreading performance rust concurrency