利用有序数据流中的数据并行性

利用有序数据流中的数据并行性 (PDF 209KB)

摘要

许多计算密集型应用都涉及从有序输入数据到有序输出数据的复杂转换。 例如声音与视频代码转换、无损数据压缩,以及地震数据处理。 然而这些转换中使用的算法通常都是并行的,所以管理 I/O 顺序依赖性将面临挑战。 本文介绍了一些此类挑战,并阐述了有关应对这些挑战,同时确保并行性能的策略。

本文是“英特尔多线程应用开发指南”系列的一部分,该系列介绍了针对英特尔® 平台开发高效多线程应用的指导原则。

背景

考虑对视频压缩引擎进行线程化,从现场视频资源到磁盘或网络客户端,该引擎专门用于实时处理未压缩的视频。 显而易见,满足此类应用的实时需求的关键便是充分利用多个处理器的性能优势。

MPEG2 和 MPEG4 等视频压缩标准专门针对在不可靠的链接上进行流处理而设计。 因此,用户可以轻松地将一个视频流作为一系列更小的单独视频流来处理。 通过以并行的方式处理这些更小的视频流,速度可得到显著的提升。 通过多线程来获得此类并行性所带来的优势将面临以下挑战:

  • 定义问题的非重叠子集,并将其分配到线程中
  • 确保输入数据可被准确、即时地读取,并确保其排列顺序正确无误
  • 无论处理完成后的实际顺序如何,都要以正确的顺序导出数据块,并确保不会对性能造成重大影响
  • 执行上述操作时,不知道输入数据的实际范围

在其它情况下,例如无损数据压缩,通常能够提前确定输入数据的大小,并明确地将数据划分为单独的输入数据块。 这里所述的方法同样适用于本例。

建议

使用该方法可能是希望创建一个生产者和消费者链条,但该方法不具可扩展性,并且容易导致负载不平衡。 相反,本文所采用的数据分解能够应对上述所有挑战,从而实现扩展性更高的设计。
此处所采取的方法是创建一组线程,每个线程都能够读取视频数据块,并对其进行编码,然后将其输出到重排序缓冲区。 在完成对一个数据块的处理后,线程将返回读取并处理下一个视频数据块,以此类推。 这种动态分配方法可以最大限度地减少发生负载不平衡的可能性。 重排序缓冲区能够确保以正确的顺序写入编码的视频数据块,而不受其完成时的顺序的影响。

初始视频编码算法可能采取这种形式:

inFile = OpenFile ()

	outFile == InitializeOutputFile ()

	WriteHeader (outFile)

	outputBuffer = AllocateBuffer (bufferSize)

	while (frame = ReadNextFrame (inFile))

	{

	EncodeFrame (frame, outputBuffer)

	if (outputBuffer size > bufferThreshold)

	FlushBuffer(outputBuffer, outFile)

	}

	FlushBuffer (outputBuffer, outFile)

	

首先要利用基于数据块的算法来替换读取和编码帧的结果,提出在一组线程内进行分解的问题。

WriteHeader (outFile)

	while (block = ReadNextBlock (inFile))

	{

	while(frame = ReadNextFrame (block))

	{

	EncodeFrame (frame, outputBuffer)

	if (outputBuffer size > bufferThreshold)

	FlushBuffer (outputBuffer, outFile)

	}

	FlushBuffer (outputBuffer, outFile)

	}

	

数据块的定义因应用的不同而有所差异,但在视频数据流情况下,自然数据块边界可能是第一个帧,在该帧的输入中检测到场景发生变化,并且会受到最小和最大数据块的限制。 基于数据块的处理要求在处理前对输入缓冲进行分配,并对源代码进行少量变更以填满缓冲区。 同样,必需对 readNextFrame 方法进行更改,以便从缓冲区(而非文件)读取。

接下来改变输出缓冲策略,以确保能够将整个数据块作为一个单元进行写入。 这种方法显著简化了输出重新排序的方式,因为只要确保以正确的顺序输出数据块即可。 以下代码反映了对基于数据块的输出所进行的变更:

根据最大数据块的体积,可能需要较大的输出缓冲区。

因为每个数据块之间都是相互独立的,所以通常所有输出数据块会以特定的数据头为开始。 在 MPEG 视频数据流中,与已定义的未来帧相比,该数据头先于完整的帧,被称为 I 帧。 因此,该数据头被迁移到了数据块上方的循环中:

while (block = ReadNextBlock (inFile))

	{

	WriteHeader (outputBuffer)

	while (frame = ReadNextFrame (block))

	{

	EncodeFrame (frame, outputBuffer)

	}

	FlushBuffer (outputBuffer, outFile)

	}

	

借助这些变化,可以利用线程库(即,Pthreads 或 Win32 线程 API)或 OpenMP 引入并行性。

// Create a team of threads with private

	// copies of outputBuffer, block, and frame

	// and shared copies of inFile and outFile

	while (AcquireLock,

	block = ReadNextBlock (inFile),

	ReleaseLock, block)

	{

	WriteHeader (outputBuffer)

	while (frame = ReadNextFrame (block))

	{

	EncodeFrame (frame, outputBuffer)

	}

	FlushBuffer (outputBuffer, outFile)

	}


	

这是一种简单而有效的策略,有助于安全、有序地读取数据。 所有线程都需要一个锁定,读取数据块,然后释放该锁。 共享输入文件可以确保按顺序读取数据块,并且只读取一次。 因为准备线程通常需要一个锁,因此需要以动态或先到先得 (first-come-first-served) 的方式分配数据块,这样通常可以最大限度地避免负载不平衡。

最后的任务是确保能够以正确的顺序安全地输出数据块。 其中一个简单的方法是利用锁和共享的输出文件来确保一次只写入一个数据块。 这种方法能够确保线程的安全,但可能会以其它顺序(而非初始顺序)输出数据块。 另外,线程将一直处于等待状态,直到刷新输出之前写入所有之前的数据块。 很遗憾,这种方法会使效率降低,因为线程在等待写入时处于闲置状态。

更有效的方法是针对输出数据块创建循环的重新排序缓冲区。 为每个数据块指派按顺序排列的序列号。 缓冲区的“末端 (tail)”将会创建下一个将要写入的数据块。 如果线程可以完成对数据块的处理,而无需缓冲区末端的指派,那么它会将数据块排列在合适的缓冲区位置,然后返回读取并处理下一个数据块。 同样,如果线程发现刚刚完成的数据块是由缓冲区末端指派,那么它将会写入该数据块,以及之前排列的其它临近的数据块。 最后,它将更新缓冲区末端,以指向下一个将要输出的数据块。 重新排序缓冲区允许完成后的数据块以乱序的方式排列,同时确保有序地写入这些数据块。


图 1. 写入前示例重新排序缓冲区的状态。

图 1 阐述了重新排序缓冲区的一个可能的状态。 数据块 0 至 35 已经得到处理和写入,而数据块 37、38、39、40 和 42 已得到处理,正在排队等待写入。 在线程完成对数据块 36 的处理后,它将写出数据库 36 至 40,使重新排序缓冲区处于如图 2 所示的状态。 在数据块 41 完成处理之前,数据块 42 将仍然排列在队列中。


图 2. 写入后示例重新排序缓冲区的状态。

当然,您需要采取特定的预防措施,以确保该算法快速高效:

  • 读取或写入时,共享的数据结构必须处于锁定状态。
  • 缓冲区中的插槽数量必须多于线程数量。
  • 如果缓冲区中不具有合适的插槽,线程必须有效地等待。
  • 为每个线程预先分配多个输出缓冲区, 以便使指针 (pointer) 在缓冲区中排队,并避免进行无关的数据复制和内存分配。

使用输出队列时,最终算法如下所示:

inFile = OpenFile ()

	outFile == InitializeOutputFile ()

	// Create a team of threads with private

	// copies of outputBuffer, block, and frame, shared

	// copies of inFile and outFile.

	while (AcquireLock,

	block = ReadNextBlock (inFile),

	ReleaseLock, block)

	{

	WriteHeader (outputBuffer)

	while (frame = ReadNextFrame (block))

	{

	EncodeFrame (frame, outputBuffer)

	}

	QueueOrFlush (outputBuffer, outFile)

	}

	

该算法支持有序 I/O,但还可提供灵活的高性能和无序并行处理方法。

使用指南

在某些情况下,读取和写入数据的时间与处理数据所需的时间相当。 本例中,以下方法可能更实用:

  • Linux* 和 Windows* 可提供 API 来启动读写操作,以及之后进行等待或告知完成处理。 利用这些接口来预取输入数据,以及之后写入输出数据,同时执行其它计算,可以有效地规避 I/O 延迟。 在 Windows 中,通过提供 FILE_FLAG_OVERLAPPED 属性,可打开异步 I/O 的文件。 在 Linux 中,异步运算会受到许多 aio_* 函数(由 libaio 提供)的影响。
  • 当输入数据的量较大时,由于硬件试图同时为大量并发但非连续的读取提供服务,因此静态分解方法容易导致物理磁盘发生“抖动(thrashing)”。 按照以上共享文件中所述的建议,采用动态、先到先得的算法能够执行有序、连续的读取操作,从而显著提高整体 I/O 子系统的吞吐量。

仔细选择数据块的大小和数量至关重要。 通常,数量庞大的数据块可以提供最高的灵活性,从而减少负载不平衡的情况。 另一方面,数量非常小的数据块可能带来不必要的锁定开销,甚至造成数据压缩算法效率的下降。

更多资源

有关编译器优化的更完整信息,请参阅优化通知