C#教程

关注公众号 jb51net

关闭
首页 > 软件编程 > C#教程 > C#图像零内存分配处理

C#使用Span和Memory实现图像内存分配降到零的处理方法

作者:格林威

用C#做工业相机采集的工程师,或多或少都遇到过这种场景:相机帧率上来了,程序跑着跑着卡一下,过一会儿又卡一下,日志里看是GC在工作,但感觉频率太高了,本文深入分析每帧分配新数组造成的GC压力,给出基于Span和Memory的零分配解决方案,需要的朋友可以参考下

引言

用C#做工业相机采集的工程师,或多或少都遇到过这种场景:相机帧率上来了,程序跑着跑着卡一下,过一会儿又卡一下。日志里看是GC在工作,但感觉频率太高了。

高帧率采集时GC频繁触发的根源是——每帧都会分配新数组。

以500万像素Mono8图像为例,一张图占5MB内存。如果相机跑30fps,每秒就要分配150MB的新数组。如果跑100fps,每秒500MB。这样的分配频率下,GC根本没有喘息机会,Gen2 GC的STW(Stop-The-World)暂停会直接导致丢帧。

Span和Memory的核心价值,就是让这些内存分配彻底消失。

一、GC压力从哪来

传统取图逻辑通常写成这样:

void ImageCallback(IntPtr pData, int width, int height)
{
    // 👎 每次回调都分配新数组
    byte[] buffer = new byte[width * height];
    Marshal.Copy(pData, buffer, 0, buffer.Length);
    
    // 👎 ROI裁剪也分配新数组
    byte[] roiData = new byte[roiWidth * roiHeight];
    CopyROI(buffer, roiData);
    
    ProcessImage(roiData);
}

500万像素,一帧5MB。100fps就是500MB/s的内存分配速率。每秒几百个5MB的大对象疯狂涌入托管堆,Gen 0满得快,Gen 1和Gen 2也跟着频繁标记,一个Full GC的STW暂停几十到几百毫秒——相机缓冲区直接溢出,丢帧。

更隐蔽的问题是:图像数据是大对象(大于85000字节),会直接进入大对象堆(LOH,Large Object Heap,>85KB的托管对象专用区域) ,普通压缩回收对LOH根本不动,LOH爆了就是OOM。

二、传统做法 vs Span做法

传统做法:取到IntPtr → 分配byte[]拷贝过去 → 收到ROI区域时又new一次byte[] → 扔给算法处理。每帧至少两次堆分配。

Span做法:从IntPtr创建Span,从头到尾只操作这个视图,不拷贝任何东西。

byte[] imageData改成Span<byte> imageSpan,只是变量声明变了,但背后的内存模型已经完全不同:Span只是一个ref struct视图,不产生任何堆对象,不触发GC。

三、通用框架代码

3.1 从SDK回调构建Span

海康MVS SDK

void ImageCallback(IntPtr pData, ref MV_FRAME_OUT_INFO_EX pFrameInfo, IntPtr pUser)
{
    int imgSize = (int)(pFrameInfo.nWidth * pFrameInfo.nHeight);
    // ✅ 零分配:直接用Span包装非托管内存
    Span<byte> imgSpan = new Span<byte>(pData.ToPointer(), imgSize);
    
    ProcessImage(imgSpan, pFrameInfo.nWidth, pFrameInfo.nHeight);
    // 不用释放任何东西,SDK自己回收
}

Basler pylon SDK

void OnImageGrabbed(object sender, ImageGrabbedEventArgs e)
{
    IGrabResult grabResult = e.GrabResult;
    if (grabResult.GrabSucceeded)
    {
        byte[] buffer = grabResult.PixelData as byte[];
        // ✅ 零分配包装托管数组
        Span<byte> imgSpan = buffer.AsSpan();
        
        ProcessImage(imgSpan, grabResult.Width, grabResult.Height);
    }
    grabResult.Dispose();
}

区别在于:海康给的是IntPtr指针,用Span的指针构造函数包装;Basler给的是byte[],用.AsSpan()包装。但两者都没有产生新的内存分配。

3.2 零分配ROI切片

void ExtractROI(Span<byte> fullImage, int width, int height, 
                int roiX, int roiY, int roiW, int roiH)
{
    Span<byte> roiSpan = fullImage.Slice(roiY * width + roiX, roiW * roiH);
    // roiSpan指向原内存中的ROI区域,没有复制
    ProcessROI(roiSpan);
}

3.3 完整图像预处理流水线

void ProcessImage(Span<byte> imgSpan, int width, int height)
{
    // 1. 零分配ROI切片
    Span<byte> roiSpan = imgSpan.Slice(roiOffsetY * width + roiOffsetX, roiW * roiH);
    
    // 2. 伽马校正(原地修改,不分配新内存)
    ApplyGammaInPlace(roiSpan);
    
    // 3. 转给OpenCV(包装成Mat)
    using (Mat mat = new Mat(height, width, MatType.CV_8UC1, (IntPtr)Unsafe.AsPointer(ref roiSpan.GetPinnableReference())))
    {
        // mat直接共享roiSpan的内存,无拷贝
        RunInference(mat);
    }
}

void ApplyGammaInPlace(Span<byte> span)
{
    byte[] lut = _gammaLUT; // 预先生成的查找表
    for (int i = 0; i < span.Length; i++)
    {
        span[i] = lut[span[i]];
    }
}

ApplyGammaInPlace直接在原内存上修改,全程零内存分配。

四、什么时候用Span,什么时候用Memory

场景推荐类型原因
SDK回调内部处理Span同步处理,栈上操作,最快
传给异步处理方法MemorySpan不能跨异步边界
存入队列供消费者取图Memory队列元素需在堆上存储
OpenCV Mat构建SpanMat需要固定内存指针
多线程共享图像数据内存池 + Memory需手动管理所有权和生命周期

简单判断:数据不离开当前函数,用Span;数据要在任务或线程间传递,用Memory或内存池。

关于Memory和内存池的配合使用,核心思路如下:

using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(bufferSize);
Memory<byte> memory = owner.Memory;
Span<byte> span = memory.Span;
// 填充数据后传给异步处理
await ProcessAsync(memory);
// 处理完后,owner.Dispose自动将内存归还池

五、OpenCVSharp Mat如何做到零拷贝

OpenCvSharp的Mat可以直接包装现有内存:

byte[] buffer = new byte[width * height];
fixed (byte* ptr = buffer)
{
    // Mat共享buffer内存,不拷贝
    using (Mat mat = new Mat(height, width, MatType.CV_8UC1, (IntPtr)ptr))
    {
        Cv2.CvtColor(mat, matBgr, ColorConversionCodes.GRAY2BGR);
    }
}

注意这里的fixed用于固定托管数组地址,Span模式下建议直接从非托管指针创建Mat,避免fixed块限制。

六、Span vs 传统做法:实测对比

在500万像素、100fps的持续采集中:

做法每帧内存分配GC触发频率丢帧率CPU占用
传统(new byte[])~5MB每秒多次Gen0,数分钟一次Full GC
内存池+Span启动时一次性分配零GC分配降低30-50%

Span版本实测可将内存分配减少50-90%,执行时间缩短20-50%。在200fps甚至更高帧率下,差距只会更大。

七、排查GC压力的方法

如果怀疑GC是性能瓶颈,先确认问题根因,再加内存池和Span。排查工具推荐:

排查时重点关注:每秒分配字节数、GC触发次数与耗时、大对象堆占用增长率。代码集成后再跑压测,观察内存曲线是否稳定。

八、写在最后

Span和Memory不是万能药,但在图像预处理这个场景里,它们确实堵住了GC的最大漏洞——高频大对象分配。

new byte[]改成Span<byte>,配上一个内存池,让GC从每秒几百次分配降到几乎为零。代码改动不大,收益却非常直接。

工业级视觉应用,每一帧的稳定比什么都重要。GC的不确定性,是可以完全消除的。

以上就是C#使用Span和Memory实现图像内存分配降到零的处理方法的详细内容,更多关于C#图像零内存分配处理的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文