Quantcast
Channel: 英特尔开发人员专区文章
Viewing all 583 articles
Browse latest View live

2016全国并行应用挑战赛

$
0
0
 

大赛背景介绍

全国并行应用挑战赛(简称PAC) 由中国计算机学会高性能计算专业委员会指导,教育部计算机类专业教学指导委员会联合英特尔(中国)有限公司主办,北京并行科技股份有限公司承办,国家超算广州中心协办。

PAC2016采用英特尔®新一代至强融核™处理器Knights Landing(简称KNL)作为推荐平台,并由英特尔(中国)有限公司提供围绕并行计算以及KNL的相关培训内容,希望帮助参赛选手深入了解并顺利运用KNL平台进行并行程序的开发以及优化。

培训视频之外,我们也将安排一系列在线答疑,Intel专家将实时与您沟通。在线答疑的具体安排,我们将在之后通过PAC官方网站以及微信发布。

预祝大家在今年大赛中取得好的成绩!

了解更多大赛信息,请登录大赛官方网站:http://www.pac-hpc.com/,或关注大赛官方微信:PACChina-HPC

1.Intel Knights Landing 介绍

利用英特尔工具,通过英特尔至强处理器和英特尔至强融核处理器帮助开发人员实现高性能(英文)(47分36秒)

简介:为什么英特尔处理器上进行的高性能编程比其他任何平台都要多,以及Knights Landing如何继续将这一传统发扬光大?

2.Intel Omni Path 介绍

3. Intel HPC 软件开发工具

英特尔® Parallel Studio XE 2017概述和新功能(56分37秒)

简介:英特尔® Parallel Studio 2017推出若干种令人激动的功能特性以及为数不多的几种新产品。

英特尔® 数据分析加速库(DAAL)(52分22秒)

简介:数据分析在各个行业和科技领域广泛运用,可帮助决策、查找各种模式、发现各种理论等。

英特尔® HPC硬件路线图和相关软件工具(1时02分48秒)

简介:我们将预览最新的英特尔®处理器和平台发展路线图,包括新的及即将发布的处理器功能特性,以及相关软件开发,优化工具。

即使不用最新硬件也可实现最新AVX SIMD指令调优(52分56秒)

简介:向量化对于充分发挥现代处理器的全部潜能具有至关重要的作用。

英特尔® MPI运行时调谐 (1时11分46秒)

简介:本次会议专场将重点关注英特尔® MPI 运行时自身的调谐,而不需要改变应用/工作负载本身。

4.代码现代化

深入洞悉英特尔路线图,明智行事:英特尔® 至强® 与英特尔® 至强融核TM(50分12秒)

简介:英特尔将概要介绍即将发布的英特尔至强和英特尔至强融核处理器。这些处理器包含相关软件接口和工具,旨在帮助进行代码现代化工作。

OpenMP 和英特尔尔编译器(53分30秒)

简介:本课程描述了您可以用英特尔编译器开展哪些无法使用 OpenMP完成的工作,包括一些即将消失的差距,以及其他一些短期内仍会存在的差距等。

Knights Landing 上的 MCDRAM(高带宽内存) - 分析方法/工具(53分38秒)

简介:我们的教程将介绍一些用户可使用的方法/工具,以分析适合应用的内存模式。

准备好迎接新的内存技术:NUMA 编程(38分16秒)

简介:在本课程中,我们将从编程人员的角度探讨英特尔内存模型,包括纠正和性能主题。


Penn Station:一种基于标注的、随特征数量伸缩的发布订阅服务

$
0
0

Edison Wang
Twitter/Vine资深安卓工程师

构建并启动一个应用,既有趣又简单,但接下去就要维护;您不断地增添特性、事件和业务逻辑。 您的团队里增添了新成员,现在要跟踪发生了什么就不太容易了。您需要一种好方法来有条不紊地整理代码。 测试是起步的好方法。 您不断向有限状态机中增添状态,但最终发现无法再跟踪,于是您增添 Otto、EventBus、Dagger、Rx 和其他框架, 但是,在进行代码重构时,为确保调试过程有条不紊,要在编写太多样本文件代码:界面之间找到一个最佳状态却如此困难。

于是 Penn Station应运而生,这是一种利用 EventBus的安卓服务型式:受 iOS 的 Grand Central 启发,将您的应用中的事件设想为人们,他们试图从这里前往那里(请求器/侦听器),因此他们聚集在 Penn Station,在这里,他们可以前往 NJT/LIRR/NYC Metro/etc(不同的进程)。(Penn Station 是纽约一个地铁站,NJT/LIRR/NYC Metro 是新泽西/长岛/纽约市——译者注)

 Penn StationEventBusOtto
声明事件标注标注    标注
事件侦听器接口(2)标注
事件请求工厂标注
事件结果工厂标注
IPC 经由 Parcelable(如需要)
事件生产器标注
请求实施
异步交付
在张贴线程上的事件交付默认
在主线程上的事件交付默认
在背景线程上的事件交付 
请求进程    服务执行器   背景    背景
方便访问语境安卓服务

使用 Penn Station,您实施长时间运行任务的流程就十分方便,因为除了考虑需完成什么任务、何时完成该任务、应发出什么事件外,您不必担心其他任何事情。

设想您在编写一种登录用户的方法:

首先,创建一个实施 BaseAction 的 LoginAction:

  1. 通过 @RequestFactory 标注该 class
  2. 通过 @RequestFactoryWithVariables 标注该 class a. @ClassField(“username”, String.class) b. @ClassField(“password”, String.class)
  3. 通过 @EventProducer 标注该 class a. @ResultClassWithVariables(“Failed”) b. @ResultClassWithVariables(“Success”)
  4. 在 processRequest 内部实施该逻辑。
  5. 通过返回 Failed 事件实施 onError。

现在就可在任何地方使用此 LoginAction,只需声明需要 LoginAction 的任何 class 为 LoginAction.class 的 @EventListener:

  1. 实施 onEventMainThread(LoginActionEventFailed)
  2. 实施 onEventMainThread(LoginActionEventSuccess)
  3. 每当需要时,通过生成的 PsLoginActionAction 调用该动作。

这很好,因为:

  1. 封包:LoginAction 容纳所有逻辑和声明。
  2. 状态解耦: a. 不必担心生命周期:LoginAction 的生存随 Service,而非 Activity。 b. Listeners 有其自己的生命周期,如果其结束,那就结束了。
  3. 编译器辅助的代码重构: a. 如果您新增一个 result 类型,例如 “Expired”,在实施 “LoginActionEventExpired” 之前,所有 listeners 均不编译。 b. 如果要更改 request(请求),例如,增添一个 “birthday” 字段作为登录的必要参数,只需增添一次,然后每处调用 LoginAction 的地方都将要求输入该变量。

我希望,有了这个库,任何人可以编写改善用户体验的有效率、可伸缩和安全可靠的代码。

如果您有任何意见或建议,请不吝指教! :)

投资于移动应用开发的主要原因

$
0
0

Daniel Kaufman
Brooklyn Labs共同创始人

通过面向移动操作系统(安卓、Apple 等)开发移动应用,您能够在众多现有和潜在客户中提高品牌知名度和可靠性。 若干客户现在期待业务或品牌拥有其自己的可靠移动应用。 这说明,这不单单是合理地胜过其他行业的需要。 拥有专门的移动应用将增强品牌的可靠性。

请记住,移动应用的意义现在在于社会;为您的业务制作一个移动应用是明智的决定。 以下是投资于移动应用开发的一些原因。

1. 移动应用携带便携式广告

有了可以展示给客户的移动应用,您就可在客户友好的环境中,随时随地联系自己的业务。 经常使用您的应用,将强化您的品牌或业务。 这意味着,当您想买一些东西时,可能它们会出现在您面前。 您利用应用与他们形成一种关系,这相当于把业务装载用户的口袋里。

2. 世界已进入移动时代

全球已毫不犹豫地进入了移动时代,绝不回头。 客户在使用手机寻找当地商家。 您的在线品牌推广活动正被移动网络查看。 因此,只有一个网站是不够的。 客户正从台式机浏览器转向依赖于移动应用。 过时的网页在 6 英寸的手机屏幕上拥挤不堪,移动应用作为同时浏览和购买的手段而大获成功。

3. 应用提高兴趣

当您部署一个应用时,它为您提供一种简单的方法,向目前和未来的顾客展示您的产品或服务。 顾客每次想购物时,只需使用该应用作为获得所有有关信息的一站式接入点。

4. 更广大、更年轻的受众

大多数年青人很久以前就开始使用手机。 到今年年底,十几二十年龄组的人群中,75% 将拥有手机。 让年青人使用老式技术太不容易。 年轻人倾向于依赖移动设备,即使其拥有的个人电脑已过时。 智能手机已成为与亲友交谈,在线浏览和购买商品和服务的新颖工具。 要抓住这些受众,您需要移动应用。

5. 移动应用可以成为社交平台

人们对社交媒体的热情自不待言。 因此,您必须投身其中。 在移动应用中增添社交功能,如赞、评论、应用内短信等,有助于提高您的业务的社会地位。 人们在社交媒体(特别是 Facebook 和 Twitter) 上花费越来越多的时间。 因此,如果您的移动应用能为受众提供参与社交媒体的全部功能,意味着他们将在您的移动应用中停留越来越多的时间。

Threading and Intel® Integrated Performance Primitives

$
0
0

Threading and Intel® Integrated Performance Primitives (PDF 230KB)

Abstract

There is no universal threading solution that works for all applications. Likewise, there are multiple ways for applications built with Intel® Integrated Performance Primitives (Intel® IPP) to utilize multi-threading. Threading can be implemented within the Intel IPP library or at within the application that employs the Intel IPP library. The following article describes some of the ways in which an application that utilizes the Intel IPP library can safely and successfully take advantage of multiple threads of execution.

This article is part of the larger series, "Intel Guide for Developing Multithreaded Applications," which provides guidelines for developing efficient multithreaded applications for Intel® platforms.
 

Background

Intel IPP is a collection of highly optimized functions for digital media and data-processing applications. The library includes optimized functions for frequently used fundamental algorithms found in a variety of domains, including signal processing, image, audio, and video encode/decode, data compression, string processing, and encryption. The library takes advantage of the extensive SIMD (single instruction multiple data) and SSE (streaming SIMD extensions) instruction sets and multiple hardware execution threads available in modern Intel® processors. Many of the SSE instructions found in today's processors are modeled after those on DSPs (digital signal processors) and are ideal for optimizing algorithms that operate on arrays and vectors of data.

The Intel IPP library is available for applications built for the Windows*, Linux*, Mac OS* X, QNX*, and VxWorks* operating systems. It is compatible with the Intel® C and Fortran Compilers, the Microsoft Visual Studio* C/C++ compilers, and the gcc compilers included with most Linux distributions. The library has been validated for use with multiple generations of Intel and compatible AMD processors, including the Intel® Core™ and Intel® Atom™ processors. Both 32-bit and 64-bit operating systems and architectures are supported.
 

Introduction

The Intel IPP library has been constructed to accommodate a variety of approaches to multithreading. The library is thread-safe by design, meaning that the library functions can be safely called from multiple threads within a single application. Additionally, variants of the library are provided with multithreading built in, by way of the Intel OpenMP* library, giving you an immediate performance boost without requiring that your application be rewritten as a multithreaded application.

The Intel IPP primitives (the low-level functions that comprise the base Intel IPP library) are a collection of algorithmic elements designed to operate repetitively on data vectors and arrays, an ideal condition for the implementation of multithreaded applications. The primitives are independent of the underlying operating system; they do not utilize locks, semaphores, or global variables, and they rely only on the standard C library memory allocation routines (malloc/realloc/calloc/free) for temporary and state memory storage. To further reduce dependency on external system functions, you can use the i_malloc interface to substitute your own memory allocation routines for the standard C routines.

In addition to the low-level algorithmic primitives, the Intel IPP library includes a collection of industry-standard, high-level applications and tools that implement image, media and speech codecs (encoders and decoders), data compression libraries, string processing functions, and cryptography tools. Many of these high-level tools use multiple threads to divide the work between two or more hardware threads.

Even within a singled-threaded application, the Intel IPP library provides a significant performance boost by providing easy access to SIMD instructions (MMX, SSE, etc.) through a set of functions designed to meet the needs of numerically intensive algorithms.

Figure 1 shows relative average performance improvements measured for the various Intel IPP product domains, as compared to the equivalent functions implemented without the aid of MMX/SSE instructions. Your actual performance improvement will vary.



Figure 1. Relative performance improvements for various Intel® IPP product domains.

System configuration: Intel® Xeon® Quad-Core Processor, 2.8GHz, 2GB using Windows* XP and the Intel IPP 6.0 library
 

Advice

The simplest and quickest way to take advantage of multiple hardware threads with the Intel IPP library is to use a multi-threaded variant of the library or to incorporate one of the many threaded sample applications provided with the library. This requires no significant code rework and can provide additional performance improvements beyond those that result simply from the use of Intel IPP.

Three fundamental variants of the library are delivered (as of version 6.1): a single-threaded static library; a multithreaded static library; and a multithreaded dynamic library. All three variants of the library are thread-safe. The single-threaded static library should be used in kernel-mode applications or in those cases where the use of the OpenMP library either cannot be tolerated or is not supported (as may be the case with a real-time operating system).

Two Threading Choices: OpenMP Threading and Intel IPP
The low-level primitives within Intel IPP are basic atomic operations, which limits the amount of parallelism that can be exploited to approximately 15% of the library functions. The Intel OpenMP library has been utilized to implement this "behind the scenes" parallelism and is enabled by default when using one of the multi-threaded variants of the library.

A complete list of the multi-threaded primitives is provided in the ThreadedFunctionsList.txt file located in the Intel IPP documentation directory.

Note: the fact that the Intel IPP library is built with the Intel C compiler and OpenMP does not require that applications must also be built using these tools. The Intel IPP library primitives are delivered in a binary format compatible with the C compiler for the relevant operating system (OS) platform and are ready to link with applications. Programmers can build applications that use Intel IPP application with Intel tools or other preferred development tools.

Controlling OpenMP Threading in the Intel IPP Primitives
The default number of OpenMP threads used by the threaded Intel IPP primitives is equal to the number of hardware threads in the system, which is determined by the number and type of CPUs present. For example, a quad-core processor that supports Intel® Hyper-Threading Technology (Intel® HT Technology) has eight hardware threads (each of four cores supports two threads). A dual-core CPU that does not include Intel HT Technology has two hardware threads.

Two Intel IPP primitives provide universal control and status regarding OpenMP threading within the multi-threaded variants of the Intel IPP library: ippSetNumThreads() and ippGetNumThreads(). Call ippGetNumThreads to determine the current thread cap and use ippSetNumThreads to change the thread cap. ippSetNumThreads will not allow you to set the thread cap beyond the number of available hardware threads. This thread cap serves as an upper bound on the number of OpenMP software threads that can be used within a multi-threaded primitive. Some Intel IPP functions may use fewer threads than specified by the thread cap in order to achieve their optimum parallel efficiency, but they will never use more than the thread cap allows.

To disable OpenMP within a threaded variant of the Intel IPP library, call ippSetNumThreads(1) near the beginning of the application, or link the application with the Intel IPP single-threaded static library.

The OpenMP library references several configuration environment variables. In particular, OMP_NUM_THREADS sets the default number of threads (the thread cap) to be used by the OpenMP library at run time. However, the Intel IPP library will override this setting by limiting the number of OpenMP threads used by an application to be either the number of hardware threads in the system, as described above, or the value specified by a call to ippSetNumThreads, whichever is lower. OpenMP applications that do not use the Intel IPP library may still be affected by the OMP_NUM_THREADS environment variable. Likewise, such OpenMP applications are not affected by a call to the ippSetNumThreads function within Intel IPP applications.

Nested OpenMP
If an Intel IPP application also implements multithreading using OpenMP, the threaded Intel IPP primitives the application calls may execute as single-threaded primitives. This happens if the Intel IPP primitive is called within an OpenMP parallelized section of code and if nested parallelization has been disabled (which is the default case) within the Intel OpenMP library.

Nesting of parallel OpenMP regions risks creating a large number of threads that effectively oversubscribe the number of hardware threads available. Creating parallel region always incurs overhead, and the overhead associated with the nesting of parallel OpenMP regions may outweigh the benefit. In general, OpenMP threaded applications that use the Intel IPP primitives should disable multi-threading within the Intel IPP library either by calling ippSetNumThreads(1) or by using the single-threaded static Intel IPP library.

Core Affinity
Some of the Intel IPP primitives in the signal-processing domain are designed to execute parallel threads that exploit a merged L2 cache. These functions (single and double precision FFT, Div, Sqrt, etc.) need a shared cache to achieve their maximum multi-threaded performance. In other words, the threads within these primitives should execute on cores located on a single die with a shared cache. To ensure that this condition is met, the following OpenMP environment variable should be set before an application using the Intel IPP library runs:

KMP_AFFINITY=compact

On processors with two or more cores on a single die, this condition is satisfied automatically and the environment variable is superfluous. However, for those systems with more than two dies (e.g., a Pentium® D processor or a multi-socket motherboard), where the cache serving each die is not shared, failing to set this OpenMP environmental variable may result in significant performance degradation for this class of multi-threaded Intel IPP primitives.
 

Usage Guidelines

Threading Within an Intel IPP Application
Many multithreaded examples of applications that use the Intel IPP primitives are provided as part of the Intel IPP library. Source code is included with all of these samples. Some of the examples implement threading at the application level, and some use the threading built into the Intel IPP library. In most cases, the performance gain due to multithreading is substantial.

When using the primitives in a multithreaded application, disabling the Intel IPP library's built-in threading is recommended, using any of the techniques described in the previous section. Doing so ensures that there is no competition between the built-in threading of the library and the application's threading mechanism, helping to avoid an oversubscription of software threads to the available hardware threads.

Most of the library primitives emphasize operations on arrays of data, because the Intel IPP library takes advantage of the processor's SIMD instructions, which are well suited to vector operations. Threading is natural for operations on multiple data elements that are largely independent. In general, the easiest way to thread with the library is by using data decomposition, or splitting large blocks of data into smaller blocks and working on those smaller blocks with multiple identical parallel threads of execution.

Memory and Cache Alignment
When working with large blocks of data, improperly aligned data will typically reduce throughput. The library includes a set of memory allocation and alignment functions to address this issue. Additionally, most compilers can be configured to pad structures to ensure bus-efficient alignment of data.

Cache alignment and the spacing of data relative to cache lines is very important when implementing parallel threads. This is especially true for parallel threads that contain constructs of looping Intel IPP primitives. If the operations of multiple parallel threads frequently utilize coincident or shared data structures, the write operations of one thread may invalidate the cache lines associated with the data structures of a "neighboring" thread.

When building parallel threads of identical Intel IPP operations (data decomposition), be sure to consider the relative spacing of the decomposed data blocks being operated on by the parallel threads and the spacing of any control data structures used by the primitives within those threads. Take especial care when the control structures hold state information that is updated on each iteration of the Intel IPP functions. If these control structures share a cache line, an update to one control structure may invalidate a neighboring structure.

The simplest solution is to allocate these control data structures so they occupy a multiple of the processor's cache line size (typically 64 bytes). Developers can also use the compiler's align operators to insure these structures, and arrays of such structures, always align with cache line boundaries. Any wasted bytes used to pad control structures will more than make up for the lost bus cycles required to refresh a cache line on each iteration of the primitives.

Pipelined Processing with DMIP
In an ideal world, applications would adapt at run-time to optimize their use of the SIMD instructions available, the number of hardware threads present, and the size of the high-speed cache. Optimum use of these three key resources might achieve near perfect parallel operation of the application, which is the essential aim behind the DMIP library that is part of Intel IPP.

The DMIP approach to parallelization, building parallel sequences of Intel IPP primitives that are executed on cache-optimal sized data blocks, enables application performance gains of several factors over those that operate sequentially over an entire data set with each function call.

For example, rather than operate over an entire image, break the image into cacheable segments and perform multiple operations on each segment, while it remains in the cache. The sequence of operations is a calculation pipeline and is applied to each tile until the entire data set is processed. Multiple pipelines running in parallel can then be built to amplify the performance.

To find out more detail about this technique, see "A Landmark in Image Processing: DMIP."

Threaded Performance Results
Additional high-level threaded library tools included with Intel IPP offer significant performance improvements when used in multicore environments. For example, the Intel IPP data compression library provides drop-in compatibility for the popular ZLIB, BZIP2, GZIP and LZO lossless data-compression libraries. The Intel IPP versions of the BZIP2 and GZIP libraries take advantage of a multithreading environment by using native threads to divide large files into many multiple blocks to be compressed in parallel, or by processing multiple files in separate threads. Using this technique, the GZIP library is able to achieve as much as a 10x performance gain on a quad-core processor, when compared to an equivalent single-threaded implementation without Intel IPP.

In the area of multi-media (e.g., video and image processing), an Intel IPP version of H.264 and VC 1 decoding is able to achieve near theoretical maximum scaling to the available hardware threads by using multiple native threads to parallelize decoding, reconstruction, and de-blocking operations on the video frames. Executing this Intel IPP enhanced H.264 decoder on a quad-core processor results in performance gains of between 3x and 4x for a high-definition bit stream.
 

Final Remarks

There is no single approach that is guaranteed to provide the best performance for all situations. The performance improvements achieved through the use of threading and the Intel IPP library depend on the nature of the application (e.g., how easily it can be threaded), the specific mix of Intel IPP primitives it can use (threaded versus non-threaded primitives, frequency of use, etc.), and the hardware platform on which it runs (number of cores, memory bandwidth, cache size and type, etc.).
 

Additional Resources

Parallel Programming Community

Intel IPP Product website

Taylor, Stewart. Optimizing Applications for Multi-Core Processors: Using the Intel® Integrated Performance Primitives, Second Edition. Intel Press, 2007.

User's Guide, Intel® Integrated Performance Primitives for Windows* OS on IA-32 Architecture, Document Number 318254-007US, March 2009. (pdf)

Reference Manual, Intel® Integrated Performance Primitives for Intel® Architecture: Deferred Mode Image Processing Library, Document Number: 319226-002US, January 2009.

Intel IPP i_malloc sample code, located in advanced-usage samples in linkage section

Wikipedia article on OpenMP*

OpenMP.org

A Landmark in Image Processing: DMIP

 

Optimization Notice

The Intel® Integrated Performance Primitives (Intel® IPP) library contains functions that are more highly optimized for Intel microprocessors than for other microprocessors. While the functions in the Intel® IPP library offer optimizations for both Intel and Intel-compatible microprocessors, depending on your code and other factors, you will likely get extra performance on Intel microprocessors.

While the paragraph above describes the basic optimization approach for the Intel® IPP library as a whole, the library may or may not be optimized to the same degree for non-Intel microprocessors for optimizations that are not unique to Intel microprocessors. These optimizations include Intel® Streaming SIMD Extensions 2 (Intel® SSE2), Intel® Streaming SIMD Extensions 3 (Intel® SSE3), and Supplemental Streaming SIMD Extensions 3 (Intel® SSSE3) instruction sets and other optimizations. Intel does not guarantee the availability, functionality, or effectiveness of any optimization on microprocessors not manufactured by Intel. Microprocessor-dependent optimizations in this product are intended for use with Intel microprocessors.

Intel recommends that you evaluate other library products to determine which best meets your requirements.

没有任何秘密的 API: Vulkan* 简介第 4 部分

$
0
0

下载  [PDF 890KB]

Github 示例代码链接


请前往: 没有任何秘密的 API: Vulkan* 简介第 3 部分: 第一个三角形


目录

教程 4: 顶点属性 – 缓冲区、图像和栅栏

教程 4: 顶点属性 – 缓冲区、图像和栅栏

我们在之前的教程中学些了一些基本知识。 教程篇幅比较长而且(我希望)介绍得比较详细。 这是因为 Vulkan* API 的学习曲线非常陡峭。 而且大家看,即使是准备最简单的应用,我们也需要了解大量的知识。

但现在我们已经打好了基础。 因此本教程篇幅会短一些,并重点介绍与 Vulkan API 相关的一些话题。 本节我们将通过从顶点缓冲区提供顶点属性,介绍绘制任意几何图形的推荐方法。 由于本课程的代码与“03 – 第一个三角形”教程的代码类似,因此我仅介绍与之不同的部分。

并介绍另外一种整理渲染代码的方法。 之前我们在主渲染循环前记录了命令缓冲区。 但在实际情况中,每帧动画都各不相同,因此我们无法预先记录所有渲染命令。 我们应该尽可能地晚一些记录和提交命令缓冲区,以最大限度地降低输入延迟,并获取最新的输入数据。 我们将正好在提交至队列之前记录命令缓冲区。 不过,一个命令缓冲区远远不够。 必须等到显卡处理完提交的命令缓冲区后,才记录相同的命令缓冲区。 此时将通过栅栏发出信号。 但每一帧都等待栅栏实在非常浪费时间,因此我们需要更多的命令缓冲区,以便交换使用。 命令缓冲区越多,所需的栅栏越多,情况也将越复杂。 本教程将展示如何组织代码,尽可能地实现代码的轻松维护性、灵活性和快速性。

指定渲染通道相关性

我们从创建渲染通道开始,方法与之前教程中所介绍的相同。 但这次我们还需要提供其他信息。 渲染通道描述渲染资源(图像/附件)的内部组织形式,使用方法,以及在渲染流程中的变化。 通过创建图像内存壁垒,可明确更改图像的布局。 如果指定了合适的渲染通道描述(初始布局、子通道布局和最终图像布局),这种操作也可以隐式地进行。 我们首选隐式过渡,因为驱动程序可以更好地执行此类过渡。

在本教程的这一部分,与之前相同,我们将初始和最终图像布局指定为“transfer src”,而将渲染通道指定为“color attachment optimal”子通道布局。 但之前的教程缺乏其他重要信息,尤其是如何使用图像(即执行哪些与图像相关的操作),以及何时使用图像(渲染管道的哪些部分使用图像)。 此类信息可在图像内存壁垒和渲染通道描述中指定。 创建图像内存壁垒时,我们指定与特定图像相关的操作类型(壁垒之前和之后的内存访问类型),而且我们还指定何时放置壁垒(壁垒之前和之后使用图像的管道阶段)。

创建渲染通道并为其提供描述时,通过子通道相关性指定相同的信息。 其他数据对驱动程序也至关重要,可更好地准备隐式壁垒。 以下源代码可创建渲染通道并准备子通道相关性。

std::vector<VkSubpassDependency> dependencies = {
  {
    VK_SUBPASS_EXTERNAL,                            // uint32_t                       srcSubpass
    0,                                              // uint32_t                       dstSubpass
    VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,           // VkPipelineStageFlags           srcStageMask
    VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,  // VkPipelineStageFlags           dstStageMask
    VK_ACCESS_MEMORY_READ_BIT,                      // VkAccessFlags                  srcAccessMask
    VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,           // VkAccessFlags                  dstAccessMask
    VK_DEPENDENCY_BY_REGION_BIT                     // VkDependencyFlags              dependencyFlags
  },
  {
    0,                                              // uint32_t                       srcSubpass
    VK_SUBPASS_EXTERNAL,                            // uint32_t                       dstSubpass
    VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,  // VkPipelineStageFlags           srcStageMask
    VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,           // VkPipelineStageFlags           dstStageMask
    VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,           // VkAccessFlags                  srcAccessMask
    VK_ACCESS_MEMORY_READ_BIT,                      // VkAccessFlags                  dstAccessMask
    VK_DEPENDENCY_BY_REGION_BIT                     // VkDependencyFlags              dependencyFlags
  }
};

VkRenderPassCreateInfo render_pass_create_info = {
  VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO,        // VkStructureType                sType
  nullptr,                                          // const void                    *pNext
  0,                                                // VkRenderPassCreateFlags        flags
  1,                                                // uint32_t                       attachmentCount
  attachment_descriptions,                          // const VkAttachmentDescription *pAttachments
  1,                                                // uint32_t                       subpassCount
  subpass_descriptions,                             // const VkSubpassDescription    *pSubpasses
  static_cast<uint32_t>(dependencies.size()),       // uint32_t                       dependencyCount&dependencies[0]                                  // const VkSubpassDependency     *pDependencies
};

if( vkCreateRenderPass( GetDevice(), &render_pass_create_info, nullptr, &Vulkan.RenderPass ) != VK_SUCCESS ) {
  std::cout << "Could not create render pass!"<< std::endl;
  return false;
}

1.Tutorial04.cpp,函数 CreateRenderPass()

子通道相关性描述不同子通道之间的相关性。 当附件以特定方式用于特定子通道(例如渲染)时,会以另一种方式用于另一个子通道(取样),因此我们可创建内存壁垒或提供子通道相关性,以描述附件在这两个子通道中的预期用法。 当然,我们推荐使用后一种选项,因为驱动程序能够(通常)以最佳的方式准备壁垒。 而且代码本身也会得到完善 — 了解代码所需的一切都收集在一个位置,一个对象中。

在我们的简单示例中,我们仅有一个子通道,但我们指定了两种相关性。 因为我们能够(而且应该)指定渲染通道(通过提供特定子通道的编号)与其外侧操作(通过提供 VK_SUBPASS_EXTERNAL 值)之间的相关性。 这里我们为渲染通道与其唯一的子通道之前执行的操作之间的颜色附件提供相关性。 第二种相关性针对子通道内和渲染通道之后执行的操作定义。

我们将讨论哪些操作。 我们仅使用一个附件,即从演示引擎(交换链)获取的图像。 演示引擎将图像用作可演示数据源。 它仅显示一个图像。 因此涉及图像的唯一操作是在“present src”布局的图像上进行“内存读取”。 该操作不会在任何正常的管道阶段中进行,而在“管道底部”阶段再现。

在渲染通道内部唯一的子通道(索引为 0)中,我们将渲染用作颜色附件的图像。 因此在该图像上进行的操作为“颜色附件写入”,在“颜色附件输入”管道阶段中(碎片着色器之后)执行。 演示并将图像返回至演示引擎后,会再次将该图像用作数据源。 因此在本示例中,渲染通道之后的操作与之前的一样: “内存读取”。

我们通过 VkSubpassDependency 成员阵列指定该数据。 创建渲染通道和 VkRenderPassCreateInfo 结构时,我们(通过 dependencyCount 成员)指定相关性阵列中的要素数量,并(通过 pDependencies)提供第一个要素的地址。 在之前的教程中,我们将这两个字段设置为 0 和 nullptr。 VkSubpassDependency 结构包含以下字段:

  • srcSubpass – 第一个(前面)子通道的索引或 VK_SUBPASS_EXTERNAL(如果希望指示子通道与渲染通道外的操作之间的相关性)。
  • dstSubpass – 第二个(后面)子通道的索引(或 VK_SUBPASS_EXTERNAL)。
  • srcStageMask – 之前(src 子通道中)使用特定附件的管道阶段。
  • dstStageMask – 之后(dst 子通道中)将使用特定附件的管道阶段。
  • srcAccessMask – src 子通道中或渲染通道前所发生的内存操作类型。
  • dstAccessMask – dst 子通道中或渲染通道后所发生的内存操作类型。
  • dependencyFlags – 描述相关性类型(区域)的标记。

图形管道创建

现在我们将创建图形管道对象。 (我们应创建面向交换链图像的帧缓冲器,不过这一步骤在命令缓冲区记录期间进行)。 我们不想渲染在着色器中进行过硬编码的几何图形, 而是想绘制任意数量的顶点,并提供其他属性(不仅仅是顶点位置)。 我们首先应该做什么?

编写着色器

首先查看用 GLSL 代码编写的顶点着色器:

#version 450

layout(location = 0) in vec4 i_Position;
layout(location = 1) in vec4 i_Color;

out gl_PerVertex
{
  vec4 gl_Position;
};

layout(location = 0) out vec4 v_Color;

void main() {
    gl_Position = i_Position;
    v_Color = i_Color;
}

2.shader.vert

尽管比教程 03 的复杂,但该着色器非常简单。

我们指定两个输入属性(named i_Position 和 i_Color)。 在 Vulkan中,所有属性必须有一个位置布局限定符。 在 Vulkan API 中指定顶点属性描述时,属性名称不重要,重要的是它们的索引/位置。 在 OpenGL* 中,我们可请求特定名称的属性位置。 在 Vulkan 中不能这样做。 位置布局限定符是唯一的方法。

接下来我们重新声明着色器中的 gl_PerVertex 模块。 Vulkan 使用着色器 I/O 模块,所以我们应该重新声明 gl_PerVertex 以明确指定该模块使用哪些成员。 如果没有指定,将使用默认定义。 但我们必须记住该默认定义 contains gl_ClipDistance[],它要求我们启用特性 shaderClipDistance(而且在 Vulkan 中,不能使用创建设备期间没有启用的特性,或可能无法正常运行的应用)。 这里我们仅使用 gl_Position 成员,因此不要求启用该特性。

然后我们指定一个与变量 v_Color 不同的附加输入,以保存顶点颜色。 在主函数中,我们将应用提供的值拷贝至相应的输入变量:position to gl_Position 和 color to v_Color。

现在查看碎片着色器,以了解如何使用属性。

#version 450

layout(location = 0) in vec4 v_Color;

layout(location = 0) out vec4 o_Color;

void main() {
  o_Color = v_Color;
}

3.shader.frag

在碎片着色器中,将与变量 v_Color 不同的输入仅拷贝至输出变量 o_Color。 两个变量都有位置布局说明符。 在顶点着色器中变量 v_Color 的位置与输出变量相同,因此它将包含定点之间插值替换的颜色值。

着色器能够以与之前相同的方式转换成 SPIR-V 汇编。 这一步骤可通过以下命令完成:

glslangValidator.exe -V -H shader.vert > vert.spv.txt

glslangValidator.exe -V -H shader.frag > frag.spv.txt

因此现在,了解哪些是我们希望在着色器中使用的属性后,我们将可以创建相应的图形管道。

顶点属性指定

在本教程中,我们将对顶点输入状态创建进行最重要的改进,为此我们指定类型变量 VkPipelineVertexInputStateCreateInfo。 在该变量中我们提供结构指示器,定义顶点输入数据类型,以及属性的数量和布局。

我们希望使用两个属性:顶点位置和顶点颜色,前者由四个浮点组件组成,后者由四个浮点值组成。 我们以交错属性布局的形式将所有顶点数据放在缓冲器中。 这表示我们将依次放置第一个顶点的位置,相同顶点的颜色,第二个顶点的位置,第二个顶点的颜色,第三个顶点的位置和颜色,依此类推。 我们借助以下代码完成这种指定:

std::vector<VkVertexInputBindingDescription> vertex_binding_descriptions = {
  {
    0,                                                          // uint32_t                                       binding
    sizeof(VertexData),                                         // uint32_t                                       stride
    VK_VERTEX_INPUT_RATE_VERTEX                                 // VkVertexInputRate                              inputRate
  }
};

std::vector<VkVertexInputAttributeDescription> vertex_attribute_descriptions = {
  {
    0,                                                          // uint32_t                                       location
    vertex_binding_descriptions[0].binding,                     // uint32_t                                       binding
    VK_FORMAT_R32G32B32A32_SFLOAT,                              // VkFormat                                       format
    offsetof(struct VertexData, x)                              // uint32_t                                       offset
  },
  {
    1,                                                          // uint32_t                                       location
    vertex_binding_descriptions[0].binding,                     // uint32_t                                       binding
    VK_FORMAT_R32G32B32A32_SFLOAT,                              // VkFormat                                       format
    offsetof( struct VertexData, r )                            // uint32_t                                       offset
  }
};

VkPipelineVertexInputStateCreateInfo vertex_input_state_create_info = {
  VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO,    // VkStructureType                                sType
  nullptr,                                                      // const void                                    *pNext
  0,                                                            // VkPipelineVertexInputStateCreateFlags          flags;
  static_cast<uint32_t>(vertex_binding_descriptions.size()),    // uint32_t                                       vertexBindingDescriptionCount&vertex_binding_descriptions[0],                              // const VkVertexInputBindingDescription         *pVertexBindingDescriptions
  static_cast<uint32_t>(vertex_attribute_descriptions.size()),  // uint32_t                                       vertexAttributeDescriptionCount&vertex_attribute_descriptions[0]                             // const VkVertexInputAttributeDescription       *pVertexAttributeDescriptions
};

4.Tutorial04.cpp,函数 CreatePipeline()

首先通过 VkVertexInputBindingDescription 指定顶点数据绑定(通用内存信息)。 它包含以下字段:

  • binding – 与顶点数据相关的绑定索引。
  • stride – 两个连续要素(两个相邻顶点的相同属性)之间的间隔(字节)。
  • inputRate – 定义如何使用数据,是按照顶点还是按照实例使用。

步长和 inputRate 字段不言而喻。 绑定成员可能还要求提供其他信息。 创建顶点缓冲区时,在执行渲染操作之前我们将其绑定至所选的插槽。 插槽编号(索引)就是这种绑定,此处我们描述该插槽中的数据如何与内存对齐,以及如何使用数据(按顶点或实例)。 不同的顶点缓冲区可绑定至不同的绑定。 而且每个绑定都可放在内存中的不同位置。

接下来定义所有顶点属性。 我们必须指定各属性的位置(索引)(与着色器源代码相同,以位置布局限定符的形式)、数据源(从哪个绑定读取数据)、格式(数据类型和组件数量),以及查找特定属性数据的偏移(从特定顶点数据的开头,而非所有顶点数据的开头)。 这种情况与 OpenGL 几乎相同,我们创建顶点缓冲区对象(VBO,可视作等同于“绑定”),并使用 glVertexAttribPointer() 函数(通过该函数指定属性索引(位置)、大小和类型(组件数量和格式)、步长和偏移)定义属性。 可通过 VkVertexInputAttributeDescription 结构提供这类信息。 它包含以下字段:

  • location – 属性索引,与着色器源代码中由位置布局说明符定义的相同。
  • binding – 供数据读取的插槽编号(与 OpenGL 中的 VBO 等数据源),与 VkVertexInputBindingDescription 结构和 vkCmdBindVertexBuffers()函数(稍后介绍)中的绑定相同。
  • format – 数据类型和每个属性的组件数量。
  • offset – 特定属性数据的开头。

准备好后,我们可以通过填充类型变量 VkPipelineVertexInputStateCreateInfo 准备顶点输入状态描述,该变量包含以下字段:

  • sType – 结构类型,此处应等于 VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO。
  • pNext – 为扩展功能预留的指示器。 目前将该数值设为 null。
  • flags – 留作将来使用的参数。
  • vertexBindingDescriptionCount – pVertexBindingDescriptions 阵列中的要素数量。
  • pVertexBindingDescriptions – 描述为特定管道(支持读取所有属性的缓冲区)定义的所有绑定的阵列。
  • vertexAttributeDescriptionCount – pVertexAttributeDescriptions 阵列中的要素数量。
  • pVertexAttributeDescriptions – 指定所有顶点属性的要素阵列。

它包含创建管道期间的顶点属性指定。 如要使用它们,我们必须创建顶点缓冲区,并在发布渲染命令之前将其绑定至命令缓冲区。

输入汇编状态指定

之前我们使用三角形条拓扑绘制了一个简单的三角形。 现在我们绘制一个四边形,通过定义四个顶点(而非两个三角形和六个顶点)绘制起来非常方便。 为此我们必须使用三角形条带拓扑。 我们通过 VkPipelineInputAssemblyStateCreateInfo 结构定义该拓扑,其中包含以下成员:

  • sType – 结构类型,此处等于 VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO。
  • pNext – 为扩展功能预留的指示器。
  • flags – 留作将来使用的参数。
  • topology – 用于绘制顶点的拓扑(比如三角扇、带、条)。
  • primitiveRestartEnable – 该参数定义是否希望使用特定顶点索引值重新开始汇编基元。

以下简单代码可用于定义三角条带拓扑:

VkPipelineInputAssemblyStateCreateInfo input_assembly_state_create_info = {
  VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,  // VkStructureType                                sType
  nullptr,                                                      // const void                                    *pNext
  0,                                                            // VkPipelineInputAssemblyStateCreateFlags        flags
  VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP,                         // VkPrimitiveTopology                            topology
  VK_FALSE                                                      // VkBool32                                       primitiveRestartEnable
};

5.Tutorial04.cpp,函数 CreatePipeline()

视口状态指定

本教程引进了另外一个变化。 之前为了简单起见,我们对视口和 scissor 测试参数进行了硬编码,可惜导致图像总保持相同的大小,无论应用窗口多大。 这次我们不通过 VkPipelineViewportStateCreateInfo 结构指定这些数值, 而是使用动态。 以下代码负责定义静态视口状态参数:

VkPipelineViewportStateCreateInfo viewport_state_create_info = {
  VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,        // VkStructureType                                sType
  nullptr,                                                      // const void                                    *pNext
  0,                                                            // VkPipelineViewportStateCreateFlags             flags
  1,                                                            // uint32_t                                       viewportCount
  nullptr,                                                      // const VkViewport                              *pViewports
  1,                                                            // uint32_t                                       scissorCount
  nullptr                                                       // const VkRect2D                                *pScissors
};

6.Tutorial04.cpp,函数 CreatePipeline()

定义静态视口参数的结构包含以下成员:

  • sType – 结构类型,此处为 VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO。
  • pNext – 为特定于扩展的参数预留的指示器。
  • flags – 留作将来使用的参数。
  • viewportCount – 视口数量。
  • pViewports – 定义静态视口参数的结构指示器。
  • scissorCount – scissor 矩形的数量(数值必须与 viewportCount 参数相同)。
  • pScissors – 定义视口的静态 scissor 测试参数的 2D 矩形阵列指示器。

如果希望通过动态定义视口和 scissor 参数,则无需填充 pViewports 和 pScissors 成员。 因此在上述示例中将其设置为 null。 但我们必须定义视口和 scissor 测试矩形的数量。 通常通过 VkPipelineViewportStateCreateInfo 结构指定这些值,无论是否希望使用动态或静态视口和 scissor 状态。

动态指定

创建管道时,我们可以指定哪部分始终保持静态 — 在管道创建期间通过结构定义,哪部分保持动态 — 在命令缓冲区记录期间通过调用相应的函数来指定。 这可帮助我们减少仅在细节方面有所差异(比如线条宽度、混合常量、模板参数或之前提到的视口大小)的管道对象的数量。 以下代码用于定义管道应保持动态的部分:

std::vector<VkDynamicState> dynamic_states = {
  VK_DYNAMIC_STATE_VIEWPORT,
  VK_DYNAMIC_STATE_SCISSOR,
};

VkPipelineDynamicStateCreateInfo dynamic_state_create_info = {
  VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,         // VkStructureType                                sType
  nullptr,                                                      // const void                                    *pNext
  0,                                                            // VkPipelineDynamicStateCreateFlags              flags
  static_cast<uint32_t>(dynamic_states.size()),                 // uint32_t                                       dynamicStateCount&dynamic_states[0]                                            // const VkDynamicState                          *pDynamicStates
};

7.Tutorial04.cpp,函数 CreatePipeline()

该步骤通过类型结构 VkPipelineDynamicStateCreateInfo 完成,其中包含以下字段:

  • sType – 定义特定结构类型的参数,此处等于 VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO。
  • pNext – 为扩展功能预留的参数。
  • flags – 留作将来使用的参数。
  • dynamicStateCount – pDynamicStates 阵列中的要素数量。
  • pDynamicStates – 包含 enum 的阵列,指定将哪部分管道标记为动态。 该阵列的要素类型为 VkDynamicState。

管道对象创建

定义完图形管道的所有必要参数后,将可以开始创建管道对象。 以下代码可帮助完成这一操作:

VkGraphicsPipelineCreateInfo pipeline_create_info = {
  VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,              // VkStructureType                                sType
  nullptr,                                                      // const void                                    *pNext
  0,                                                            // VkPipelineCreateFlags                          flags
  static_cast<uint32_t>(shader_stage_create_infos.size()),      // uint32_t                                       stageCount&shader_stage_create_infos[0],                                // const VkPipelineShaderStageCreateInfo         *pStages&vertex_input_state_create_info,                              // const VkPipelineVertexInputStateCreateInfo    *pVertexInputState;&input_assembly_state_create_info,                            // const VkPipelineInputAssemblyStateCreateInfo  *pInputAssemblyState
  nullptr,                                                      // const VkPipelineTessellationStateCreateInfo   *pTessellationState&viewport_state_create_info,                                  // const VkPipelineViewportStateCreateInfo       *pViewportState&rasterization_state_create_info,                             // const VkPipelineRasterizationStateCreateInfo  *pRasterizationState&multisample_state_create_info,                               // const VkPipelineMultisampleStateCreateInfo    *pMultisampleState
  nullptr,                                                      // const VkPipelineDepthStencilStateCreateInfo   *pDepthStencilState&color_blend_state_create_info,                               // const VkPipelineColorBlendStateCreateInfo     *pColorBlendState&dynamic_state_create_info,                                   // const VkPipelineDynamicStateCreateInfo        *pDynamicState
  pipeline_layout.Get(),                                        // VkPipelineLayout                               layout
  Vulkan.RenderPass,                                            // VkRenderPass                                   renderPass
  0,                                                            // uint32_t                                       subpass
  VK_NULL_HANDLE,                                               // VkPipeline                                     basePipelineHandle
  -1                                                            // int32_t                                        basePipelineIndex
};

if( vkCreateGraphicsPipelines( GetDevice(), VK_NULL_HANDLE, 1, &pipeline_create_info, nullptr, &Vulkan.GraphicsPipeline ) != VK_SUCCESS ) {
  std::cout << "Could not create graphics pipeline!"<< std::endl;
  return false;
}
return true;

8.Tutorial04.cpp,函数 CreatePipeline()

最重要的变量(包含对所有管道参数的引用)的类型为 VkGraphicsPipelineCreateInfo。 与之前教程相比,唯一的变化是添加了 pDynamicState 参数,以指出 VkPipelineDynamicStateCreateInfo 结构的类型,如上所示。 每个指定为动态的管道状态均在命令缓冲区记录期间通过相应的函数调用进行设置。

通过调用 vkCreateGraphicsPipelines()函数创建管道对象。

顶点缓冲区创建

如要使用顶点属性,除了在创建管道期间指定它们外,还需准备包含所有这些属性数据的缓冲区。 我们将从该缓冲区读取属性值并将其提供给顶点着色器。

在 Vulkan 中,缓冲区和图像创建包含至少两个阶段: 首先创建对象本身。 然后,我们需要创建内存对象,该对象之后将绑定至缓冲区(或图像)。 缓冲区将从该内存对象中提取存储空间。 这种方法有助于我们指定针对内存的其他参数,并通过更多细节对其进行控制。

我们调用 vkCreateBuffer()创建(通用)缓冲区对象。 它从其他参数中接受类型变量 VkBufferCreateInfo 的指示器,以定义已创建缓冲区的参数。 以下代码负责创建用于顶点属性数据源的缓冲区:

VertexData vertex_data[] = {
  {
    -0.7f, -0.7f, 0.0f, 1.0f,
    1.0f, 0.0f, 0.0f, 0.0f
  },
  {
    -0.7f, 0.7f, 0.0f, 1.0f,
    0.0f, 1.0f, 0.0f, 0.0f
  },
  {
    0.7f, -0.7f, 0.0f, 1.0f,
    0.0f, 0.0f, 1.0f, 0.0f
  },
  {
    0.7f, 0.7f, 0.0f, 1.0f,
    0.3f, 0.3f, 0.3f, 0.0f
  }
};

Vulkan.VertexBuffer.Size = sizeof(vertex_data);

VkBufferCreateInfo buffer_create_info = {
  VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,             // VkStructureType        sType
  nullptr,                                          // const void            *pNext
  0,                                                // VkBufferCreateFlags    flags
  Vulkan.VertexBuffer.Size,                         // VkDeviceSize           size
  VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,                // VkBufferUsageFlags     usage
  VK_SHARING_MODE_EXCLUSIVE,                        // VkSharingMode          sharingMode
  0,                                                // uint32_t               queueFamilyIndexCount
  nullptr                                           // const uint32_t        *pQueueFamilyIndices
};

if( vkCreateBuffer( GetDevice(), &buffer_create_info, nullptr, &Vulkan.VertexBuffer.Handle ) != VK_SUCCESS ) {
  std::cout << "Could not create a vertex buffer!"<< std::endl;
  return false;
}

9.Tutorial04.cpp,函数 CreateVertexBuffer()

我们在 CreateVertexBuffer() 函数开头定义了大量用于位置和颜色属性的数值。 首先为第一个顶点定义四个位置组件,然后为相同的顶点定义四个颜色组件,之后为第二个顶点定义四个有关位置属性的组件,然后为相同顶点定义四个颜色值,之后依次为第三个和第四个顶点定义位置和颜色值。 阵列大小用于定义缓冲区大小。 但请记住,内部显卡驱动程序要求缓冲区的存储大于应用请求的大小。

接下来定义 VkBufferCreateInfo 类型变量。 该结构包含以下字段:

  • sType – 结构类型,应设置为 VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO 。
  • pNext – 为扩展功能预留的参数。
  • flags – 定义其他创建参数的参数。 此处它支持创建通过稀疏内存备份的缓冲区(类似于宏纹理)。 我们不想使用稀疏内存,因此将该参数设为 0。
  • size – 缓冲区的大小(字节)。
  • usage – 定义将来打算如何使用该缓冲区的参数。 我们可以指定为将该缓冲区用作统一缓冲区、索引缓冲区、传输(拷贝)操作数据源等。 这里我们打算将该缓冲区用作顶点缓冲区。 请记住,我们不能将该缓冲区用于缓冲器创建期间未定义的目的。
  • sharingMode – 共享模式,类似于交换链图像,定义特定缓冲区能否供多个队列同时访问(并发共享模式),还是仅供单个队列访问(专有共享模式)。 如果指定为并发共享模式,那么必须提供所有将访问缓冲区的队列的索引。 如果希望定义为专有共享模式,我们仍然可以在不同队列中引用该缓冲区,但一次仅引用一个。 如果希望在不同的队列中使用缓冲区(提交将该缓冲区引用至另一队列的命令),我们需要指定缓冲区内存壁垒,以将缓冲区的所有权移交至另一队列。
  • queueFamilyIndexCount – pQueueFamilyIndices 阵列中的队列索引数量(仅指定为并发共享模式时)。
  • pQueueFamilyIndices – 包含所有队列(将引用缓冲区)的索引阵列(仅指定为并发共享模式时)。

为创建缓冲区,我们必须调用 vkCreateBuffer()函数。

缓冲区内存分配

我们接下来创建内存对象,以备份缓冲区存储。

VkMemoryRequirements buffer_memory_requirements;
vkGetBufferMemoryRequirements( GetDevice(), buffer, &buffer_memory_requirements );

VkPhysicalDeviceMemoryProperties memory_properties;
vkGetPhysicalDeviceMemoryProperties( GetPhysicalDevice(), &memory_properties );

for( uint32_t i = 0; i < memory_properties.memoryTypeCount; ++i ) {
  if( (buffer_memory_requirements.memoryTypeBits & (1 << i)) &&
    (memory_properties.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) ) {

    VkMemoryAllocateInfo memory_allocate_info = {
      VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,     // VkStructureType                        sType
      nullptr,                                    // const void                            *pNext
      buffer_memory_requirements.size,            // VkDeviceSize                           allocationSize
      i                                           // uint32_t                               memoryTypeIndex
    };

    if( vkAllocateMemory( GetDevice(), &memory_allocate_info, nullptr, memory ) == VK_SUCCESS ) {
      return true;
    }
  }
}
return false;

10.Tutorial04.cpp,函数 AllocateBufferMemory()

首先必须检查创建缓冲区需满足哪些内存要求。 为此我们调用 vkGetBufferMemoryRequirements()函数。 它将供内存创建的参数保存在我们在最后一个参数中提供了地址的变量中。 该变量的类型必须为 VkMemoryRequirements,并包含与所需大小、内存对齐,以及支持内存类型等相关的信息。 内存类型有哪些?

每种设备都可拥有并展示不同的内存类型 — 属性不同的尺寸堆。 一种内存类型可能是设备位于 GDDR 芯片上的本地内存(因此速度极快)。 另一种可能是显卡和 CPU 均可见的共享内存。 显卡和应用都可以访问该内存,但这种内存的速度比(仅供显卡访问的)设备本地内存慢。

为查看可用的内存堆和类型,我们需要调用 vkGetPhysicalDeviceMemoryProperties()函数,将相关内存信息保存在类型变量 VkPhysicalDeviceMemoryProperties 中。 它包含以下信息:

  • memoryHeapCount – 特定设备展示的内存堆数量。
  • memoryHeaps – 内存堆阵列。 每个堆代表大小和属性各不相同的内存。
  • memoryTypeCount – 特定设备展示的内存类型数量。
  • memoryTypes – 内存类型阵列。 每个要素描述特定的内存属性,并包含拥有这些特殊属性的内存堆索引。

为特定缓冲区分配内存之前,我们需要查看哪种内存类型满足缓冲区的内存要求。 如果还有其他特定的需求,我们也需要检查。 为此,我们迭代所有可用的内存类型。 缓冲区内存要求中有一个称为 memoryTypeBits 的字段,如果在该字段中设置特定索引的位,表示我们可以为特定缓冲区分配该索引代表的类型的内存。 但必须记住,尽管始终有一种内存类型满足缓冲区的内存要求,但可能不支持其他特定需求。 在这种情况下,我们需要查看另一种内存类型,或更改其他要求。

这里我们的其他要求指内存需要对主机是可见的, 表示应用可以映射并访问该内存 — 读写数据。 这种内存的速度通常比设备本地内存慢,但这样我们可以轻松为顶点属性上传数据。 接下来的教程将介绍如何使用设备本地内存,以提升性能。

幸运的是,主机可见要求非常普遍,因此我们能够轻松找到一种内存类型既能满足缓冲区的内存需求,也具备主机可见性。 然后我们准备类型变量 VkMemoryAllocateInfo,并填充其中的字段:

  • sType – 结构类型,此处设置为 VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO。
  • pNext – 为扩展功能预留的指示器。
  • allocationSize – 要求分配的最小内存。
  • memoryTypeIndex – 希望用于已创建内存对象的内存类型索引。 该索引的其中一位在缓冲区内存要求中设置(值为 1)。

填充该结构后,我们调用 vkAllocateMemory()并检查内存对象分配是否成功。

绑定缓冲区内存

创建完内存对象后,必须将其绑定至缓冲区。 如果不绑定,缓冲区中将没有存储空间,我们将无法保存任何数据。

if( !AllocateBufferMemory( Vulkan.VertexBuffer.Handle, &Vulkan.VertexBuffer.Memory ) ) {
  std::cout << "Could not allocate memory for a vertex buffer!"<< std::endl;
  return false;
}

if( vkBindBufferMemory( GetDevice(), Vulkan.VertexBuffer.Handle, Vulkan.VertexBuffer.Memory, 0 ) != VK_SUCCESS ) {
  std::cout << "Could not bind memory for a vertex buffer!"<< std::endl;
  return false;
}

11.Tutorial04.cpp,函数 CreateVertexBuffer()

函数 AllocateBufferMemory() 可分配内存对象, 之前已介绍过。 创建内存对象时,我们通过调用 vkBindBufferMemory()函数,将其绑定至缓冲区。 在调用期间,我们必须指定缓冲区句柄、内存对象句柄和偏移。 偏移非常重要,需要我们另加介绍。

查询缓冲区内存要求时,我们获取了有关所需大小、内存类型和对齐方面的信息。 不同的缓冲区用法要求不同的内存对齐。 内存对象的开头(偏移为 0)满足所有对齐要求。 这表示所有内存对象将在满足所有不同用法要求的地址创建。 因此偏移指定为 0 时,我们完全不用担心。

但我们可以创建更大的内存对象,并将其用作多个缓冲区(或图像)的存储空间。 事实上我们建议使用这种方法。 创建大型内存对象表示我们只需创建较少的内存对象。 这有利于驱动程序跟踪较少数量的对象。 出于操作系统要求和安全措施,驱动程序必须跟踪内存对象。 大型内存对象不会造成较大的内存碎片问题。 最后,我们还应分配较多的内存数量,保持对象的相似性,以提高缓存命中率,从而提升应用性能。

但当我们分配大型内存对象并将其绑定至多个缓冲区(或图像)时,并非所有对象都能绑定在 0 偏移的位置。 只有一种可以如此,其他必须绑定得远一些,在第一个缓冲区(或图像)使用的空间之后。 因此第二个,以及绑定至相同内存对象的缓冲区的偏移必须满足查询报告的对齐要求。 而且我们必须牢记这点。 因此对齐成员至关重要。

创建缓冲区,并为其分配和绑定内存时,我们可以用顶点属性数据填充该缓冲区。

上传顶点数据

我们创建了缓冲区,并绑定了主机可见的内存。 这表示我们可以映射该内存、获取该内存的指示器,并使用该指示器将数据从应用拷贝至该缓冲区(与 OpenGL 的 glBufferData() 函数类似):

void *vertex_buffer_memory_pointer;
if( vkMapMemory( GetDevice(), Vulkan.VertexBuffer.Memory, 0, Vulkan.VertexBuffer.Size, 0, &vertex_buffer_memory_pointer ) != VK_SUCCESS ) {
  std::cout << "Could not map memory and upload data to a vertex buffer!"<< std::endl;
  return false;
}

memcpy( vertex_buffer_memory_pointer, vertex_data, Vulkan.VertexBuffer.Size );

VkMappedMemoryRange flush_range = {
  VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,            // VkStructureType        sType
  nullptr,                                          // const void            *pNext
  Vulkan.VertexBuffer.Memory,                       // VkDeviceMemory         memory
  0,                                                // VkDeviceSize           offset
  VK_WHOLE_SIZE                                     // VkDeviceSize           size
};
vkFlushMappedMemoryRanges( GetDevice(), 1, &flush_range );

vkUnmapMemory( GetDevice(), Vulkan.VertexBuffer.Memory );

return true;

12.Tutorial04.cpp,函数 CreateVertexBuffer()

我们调用 vkMapMemory()函数映射内存。 在该调用中,必须指定我们希望映射哪个内存对象以及访问区域。 区域表示内存对象的存储与大小开头的偏移。 调用成功后我们获取指示器。 我们可以使用该指示器将数据从应用拷贝至提供的内存地址。 这里我们从包含顶点位置和颜色的阵列拷贝顶点数据。

内存拷贝操作之后,取消内存映射之前(无需取消内存映射,可以保留指示器,不会影响性能),我们需要告知驱动程序我们的操作修改了哪部分内存。 该操作称为 flushing。 尽管如此,我们指定所有内存范围,以支持应用将数据拷贝至该范围。 范围无需具备连续性。 通过包含以下字段的 VkMappedMemoryRange 要素阵列可定义该范围:

  • sType – 结构类型,此处等于 VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE。
  • pNext – 为扩展功能预留的指示器。
  • memory – 已映射并修改的内存对象句柄。
  • offset – 特定范围开始的偏移(从特定内存对象的存储开头)。
  • size – 受影响区域的大小(字节)。 如果从偏移到结尾的整个内存均受影响,我们可以使用 VK_WHOLE_SIZE 的特定值。

定义应闪存的所有内存范围时,我们可调用 vkFlushMappedMemoryRanges()函数。 之后,驱动程序将知道哪些部分已修改,并重新加载它们(即刷新高速缓存)。 重新加载通常在壁垒上执行。 修改缓冲区后,我们应设置缓冲区内存壁垒,告知驱动程序部分操作对缓冲区造成了影响,应进行刷新。 不过幸运的是,在本示例中,驱动程序隐式地将壁垒放在命令缓冲区(引用特定缓冲区且不要求其他操作)的提交操作上。 现在我们可以在渲染命令记录期间使用该缓冲区。

渲染资源创建

现在必须准备命令缓冲区记录所需的资源。 我们在之前的教程中为每个交换链图像记录了一个静态命令缓冲区。 这里我们将重新整理渲染代码。 我们仍然展示一个比较简单的静态场景,不过此处介绍的方法可以用于展示动态场景的真实情况。

要想有效地记录命令缓冲区并将其提交至队列,我们需要四类资源:命令缓冲区、旗语、栅栏和帧缓冲器。 旗语我们之前介绍过,用于内部队列同步。 而栅栏支持应用检查是否出现了特定情况,例如命令缓冲区提交至队列后是否已执行完。 如有必要,应用可以等待栅栏,直到收到信号。 一般来说,旗语用于同步队列 (GPU),栅栏用于同步应用 (CPU)。

如要渲染一帧简单的动画,我们(至少)需要一个命令缓冲区,两个旗语 — 一个用于获取交换链图像(图像可用的旗语),另一个用于发出可进行演示的信号(渲染已完成的旗语) —,一个栅栏和一个帧缓冲器。 栅栏稍后将用于检查我们是否重新记录了特定命令缓冲区。 我们将保留部分渲染资源,以调用虚拟帧虚拟帧(包含一个命令缓冲区、两个旗语、一个栅栏和一个帧缓冲器)的数量与交换链图像数量无关。

渲染算法进展如下: 我们将渲染命令记录至第一个虚拟帧,然后将其提交至队列。 然后记录另一帧(命令缓冲区)并将其提交至队列。 直到记录并提交完所有虚拟帧。 这时我们通过提取并再次重新记录最之前(最早提交)的命令缓冲区,开始重复使用帧。 然后使用另一命令缓冲区,依此类推。

此时栅栏登场。我们不允许记录已提交至队列,但在队列中的没有执行完的命令缓冲区。 在记录命令缓冲区期间,我们可以使用“simultaneous use”标记,以记录或重新提交之前已提交过的命令缓冲区。 不过这样会影响性能。 最好的方法是使用栅栏,检查命令缓冲区是否不能再使用。 如果显卡仍然在处理命令缓冲区,我们可以等待与特定命令缓冲区相关的栅栏,或将这额外的时间用于其他目的,比如改进 AI 计算,并在这之后再次检查以查看栅栏是否收到了信号。

我们应准备多少虚拟帧? 一个远远不够。 记录并提交单个命令缓冲区时,我们会立即等待,直到能够重新记录该缓冲区。 这样会导致 CPU 和 GPU 浪费时间。 GPU 的速度通常更快,因此等待 CPU 会造成 GPU 等待的时间更长。 我们应该保持 GPU 的繁忙状态。 因此我们创建了 Vulkan 等瘦 API。 使用两个虚拟帧将会显著提升性能,因为这样会大大缩短 CPU 和 GPU 的等待时间。 添加第三个虚拟帧能够进一步提升性能,但提升空间不大。 使用四组以上渲染资源没有太大意义,因为其性能提升可忽略不计(当然这取决于已渲染场景的复杂程度和类似 CPU 的物理组件或 AI 所执行的计算)。 增加虚拟帧数量时,会提高输入延迟,因为我们在 CPU 背后演示的帧为 1-3 个。 因此两个或三个虚拟帧似乎是最合理的组合,能够使性能、内存使用和输入延迟之间达到最佳动平衡。

您可能想知道虚拟帧的数量为何与交换链图像数量无关。 这种方法会影响应用的行为。 创建交换链时,我们请求所需图像的最小数量,但驱动程序允许创建更多图像。 因此不同的硬件厂商可能实施提供不同数量交换链图像的驱动程序,甚至要求相同(演示模式和最少图像数量)时也如此。 建立虚拟帧数量与交换链数量的相关性后,应用将在一个显卡上仅使用两个虚拟帧,而在另一显卡上使用四个虚拟帧。 这样会影响性能和之前提到的输入延迟。 因此我们不希望出现这种行为。 通过保持固定数量的虚拟帧,我们可以控制渲染算法,并对其进行调优以满足需求,即渲染时间与 AI 或物理计算之间的平衡。

命令池创建

分配命令缓冲区之前,我们首先需要创建一个命令池。

VkCommandPoolCreateInfo cmd_pool_create_info = {
  VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,       // VkStructureType                sType
  nullptr,                                          // const void                    *pNext
  VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT | // VkCommandPoolCreateFlags       flags
  VK_COMMAND_POOL_CREATE_TRANSIENT_BIT,
  queue_family_index                                // uint32_t                       queueFamilyIndex
};

if( vkCreateCommandPool( GetDevice(), &cmd_pool_create_info, nullptr, pool ) != VK_SUCCESS ) {
  return false;
}
return true;

13.Tutorial04.cpp,函数 CreateCommandPool()

通过调用 vkCreateCommandPool()创建命令池,其中要求我们提供类型变量 VkCommandPoolCreateInfo 的指示器。 与之前的教程相比,代码基本保持不变。 但这次添加两个其他的标记以创建命令池:

  • VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT – 表示通过命令池分配的命令缓冲区可单独重新设置。 正常来说,如果没有这一标记,我们将无法多次重新记录相同的命令缓冲区。 它必须首先重新设置。 而且从命令池创建的命令缓冲区只能一起重新设置。 指定该标记可支持我们单独重新设置命令缓冲区,而且(甚至更好)通过调用 vkBeginCommandBuffer()函数隐式地完成这一操作。
  • VK_COMMAND_POOL_CREATE_TRANSIENT_BIT – 该标记告知驱动程序通过该命令池分配的命令缓冲区将仅短时间内存在,需要经常重新记录和重新设置。 该信息可帮助优化命令缓冲区分配并以更好地方式执行。

命令缓冲区分配

命令缓冲区分配与之前的相同。

for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) {
  if( !AllocateCommandBuffers( Vulkan.CommandPool, 1, &Vulkan.RenderingResources[i].CommandBuffer ) ) {
    std::cout << "Could not allocate command buffer!"<< std::endl;
    return false;
  }
}
return true;

14.Tutorial04.cpp,函数 CreateCommandBuffers()

唯一的变化是命令缓冲区收集在渲染资源矢量中。 每个渲染资源结构均包含一个命令缓冲区、图像可用旗语、渲染已完成旗语、一个栅栏和一个帧缓冲器。 命令缓冲区循环分配。 渲染资源矢量中的要素数量可随意选择。 在本教程中,该数量为 3。

旗语创建

负责创建旗语的代码非常简单,与之前的相同:

VkSemaphoreCreateInfo semaphore_create_info = {
  VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO,      // VkStructureType          sType
  nullptr,                                      // const void*              pNext
  0                                             // VkSemaphoreCreateFlags   flags
};

for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) {
  if( (vkCreateSemaphore( GetDevice(), &semaphore_create_info, nullptr, &Vulkan.RenderingResources[i].ImageAvailableSemaphore ) != VK_SUCCESS) ||
    (vkCreateSemaphore( GetDevice(), &semaphore_create_info, nullptr, &Vulkan.RenderingResources[i].FinishedRenderingSemaphore ) != VK_SUCCESS) ) {
    std::cout << "Could not create semaphores!"<< std::endl;
    return false;
  }
}
return true;

15.Tutorial04.cpp,函数 CreateSemaphores()

栅栏创建

以下代码负责创建栅栏对象:

VkFenceCreateInfo fence_create_info = {
  VK_STRUCTURE_TYPE_FENCE_CREATE_INFO,              // VkStructureType                sType
  nullptr,                                          // const void                    *pNext
  VK_FENCE_CREATE_SIGNALED_BIT                      // VkFenceCreateFlags             flags
};

for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) {
  if( vkCreateFence( GetDevice(), &fence_create_info, nullptr, &Vulkan.RenderingResources[i].Fence ) != VK_SUCCESS ) {
    std::cout << "Could not create a fence!"<< std::endl;
    return false;
  }
}
return true;

16.Tutorial04.cpp,函数 CreateFences()

我们调用 vkCreateFence()函数,以创建栅栏对象。 它从其他参数中接受类型变量 VkFenceCreateInfo 的指示器,其中包含以下成员:

  • sType – 结构类型。 此处应设置为 VK_STRUCTURE_TYPE_FENCE_CREATE_INFO。
  • pNext – 为扩展功能预留的指示器。
  • flags – 目前该参数支持创建已收到信号的栅栏。

栅栏包含两种状态:收到信号和未收到信号。 该应用检查特定栅栏是否处于收到信号状态,否则将等待栅栏收到信号。 提交至队列的所有操作均处理完后,由 GPU 发出信号。 提交命令缓冲区时,我们可以提供一个栅栏,该栅栏将在队列执行完提交操作发布的所有命令后收到信号。 栅栏收到信号后,将由应用负责将其重新设置为未收到信号状态。

为何创建收到信号的栅栏? 渲染算法将命令记录至第一个命令缓冲区,然后是第二个命令缓冲器,之后是第三个,然后(队列中的执行结束后)再次记录至第一个命令缓冲区。 我们使用栅栏检查能否再次记录特定命令缓冲区。 那么第一次记录会怎样呢? 我们不希望第一次命令缓冲区记录和接下来的记录操作为不同的代码路径。 因此第一次发布命令缓冲区记录时,我们还检查栅栏是否已收到信号。 但因为我们没有提交特定命令缓冲区,因此与此相关的栅栏在执行完成后无法变成收到信号状态。 因此需要以已收到信号状态创建栅栏。 这样第一次记录时我们无需等待它变成已收到信号状态(因为它已经是已收到信号状态),但检查之后,我们要重新设置并立即前往记录代码。 之后我们提交命令缓冲区并提供相同的栅栏,这样在完成操作后将收到队列发来的信号。 下一次当我们希望将渲染命令记录至相同的命令缓冲区时,我们可以执行同样的操作:等待栅栏,重新设置栅栏,然后开始记录命令缓冲区。

绘制

现在我们准备记录渲染操作。 我们将正好在提交至队列之前记录命令缓冲区。 记录并提交一个命令缓冲区,然后记录并提交下一个命令缓冲区,然后记录并提交另一个。 在这之后我们提取第一个命令缓冲区,检查是否可用,并记录并将其提交至队列。

static size_t           resource_index = 0;
RenderingResourcesData ¤t_rendering_resource = Vulkan.RenderingResources[resource_index];
VkSwapchainKHR          swap_chain = GetSwapChain().Handle;
uint32_t                image_index;

resource_index = (resource_index + 1) % VulkanTutorial04Parameters::ResourcesCount;

if( vkWaitForFences( GetDevice(), 1, ¤t_rendering_resource.Fence, VK_FALSE, 1000000000 ) != VK_SUCCESS ) {
  std::cout << "Waiting for fence takes too long!"<< std::endl;
  return false;
}
vkResetFences( GetDevice(), 1, ¤t_rendering_resource.Fence );

VkResult result = vkAcquireNextImageKHR( GetDevice(), swap_chain, UINT64_MAX, current_rendering_resource.ImageAvailableSemaphore, VK_NULL_HANDLE, &image_index );
switch( result ) {
  case VK_SUCCESS:
  case VK_SUBOPTIMAL_KHR:
    break;
  case VK_ERROR_OUT_OF_DATE_KHR:
    return OnWindowSizeChanged();
  default:
    std::cout << "Problem occurred during swap chain image acquisition!"<< std::endl;
    return false;
}

if( !PrepareFrame( current_rendering_resource.CommandBuffer, GetSwapChain().Images[image_index], current_rendering_resource.Framebuffer ) ) {
  return false;
}

VkPipelineStageFlags wait_dst_stage_mask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
VkSubmitInfo submit_info = {
  VK_STRUCTURE_TYPE_SUBMIT_INFO,                          // VkStructureType              sType
  nullptr,                                                // const void                  *pNext
  1,                                                      // uint32_t                     waitSemaphoreCount
  ¤t_rendering_resource.ImageAvailableSemaphore,    // const VkSemaphore           *pWaitSemaphores
  &wait_dst_stage_mask,                                   // const VkPipelineStageFlags  *pWaitDstStageMask;
  1,                                                      // uint32_t                     commandBufferCount¤t_rendering_resource.CommandBuffer,              // const VkCommandBuffer       *pCommandBuffers
  1,                                                      // uint32_t                     signalSemaphoreCount
  ¤t_rendering_resource.FinishedRenderingSemaphore  // const VkSemaphore           *pSignalSemaphores
};

if( vkQueueSubmit( GetGraphicsQueue().Handle, 1, &submit_info, current_rendering_resource.Fence ) != VK_SUCCESS ) {
  return false;
}

VkPresentInfoKHR present_info = {
  VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,                     // VkStructureType              sType
  nullptr,                                                // const void                  *pNext
  1,                                                      // uint32_t                     waitSemaphoreCount
  ¤t_rendering_resource.FinishedRenderingSemaphore, // const VkSemaphore           *pWaitSemaphores
  1,                                                      // uint32_t                     swapchainCount
  &swap_chain,                                            // const VkSwapchainKHR        *pSwapchains&image_index,                                           // const uint32_t              *pImageIndices
  nullptr                                                 // VkResult                    *pResults
};
result = vkQueuePresentKHR( GetPresentQueue().Handle, &present_info );

switch( result ) {
  case VK_SUCCESS:
    break;
  case VK_ERROR_OUT_OF_DATE_KHR:
  case VK_SUBOPTIMAL_KHR:
    return OnWindowSizeChanged();
  default:
    std::cout << "Problem occurred during image presentation!"<< std::endl;
    return false;
}

return true;

17.Tutorial04.cpp,函数 Draw()

首先提取最近使用的渲染资源。 然后等待与该组相关的栅栏收到信号。 如果收到了信号,表示我们可以安全提取并记录命令缓冲区。 不过它还表示我们可以提取用于获取并演示在特定命令缓冲区中引用的旗语。 不能将同一个旗语用于不同的目的或两项不同的提交操作,必须等待之前的提交操作完成。 栅栏可防止我们修改命令缓冲区和旗语。 而且大家会看到,帧缓冲器也是如此。

栅栏完成后,我们重新设置栅栏并执行与正常绘制相关的操作:获取图像,记录渲染已获取图像的操作,提交命令缓冲区,并演示图像。

然后提取另一渲染资源集并执行相同的操作。 由于保留了三组渲染资源,三个虚拟帧,我们可缩短等待栅栏收到信号的时间。

记录命令缓冲区

负责记录命令缓冲区的函数很长。 此时会更长,因为我们使用顶点缓冲区和动态视口及 scissor 测试。 而且我们还创建临时帧缓冲器!

帧缓冲器的创建非常简单、快速。 同时保留帧缓冲器对象和交换链意味着,需要重新创建交换链时,我们需要重新创建这些对象。 如果渲染算法复杂,我们将有多个图像以及与之相关的帧缓冲器。 如果这些图像的大小必须与交换链图像的大小相同,那么我们需要重新创建所有图像(以纳入潜在的大小变化)。 因此最好按照需求创建帧缓冲器,这样也更方便。 这样它们的大小将始终符合要求。 帧缓冲器在面向特定图像创建的图像视图上运行。 交换链重新创建时,旧的图像将无效并消失。 因此我们必须重新创建图像视图和帧缓冲器。

在“03-第一个三角形”教程中,我们有大小固定的帧缓冲器,而且需要与交换链同时重新创建。 现在我们的帧缓冲器对象在每个虚拟帧资源组中。 记录命令缓冲器之前,我们要为将渲染的图像创建帧缓冲器,其大小与图像相同。 这样当我们重新创建交换链时,将立即调整下一帧的大小,而且新交换链图像的句柄及其图像视图将用于创建帧缓冲器。

记录使用渲染通道和帧缓冲器对象的命令缓冲区时,在队列处理命令缓冲区期间,帧缓冲器必须始终保持有效。 创建新的帧缓冲器时,命令提交至队列的操作完成后我们才开始破坏它。 不过由于我们使用栅栏,而且等待与特定命令缓冲区相关的栅栏,因为能够确保安全地破坏帧缓冲器。 然后我们创建新的帧缓冲器,以纳入潜在的大小和图像句柄变化。

if( framebuffer != VK_NULL_HANDLE ) {
  vkDestroyFramebuffer( GetDevice(), framebuffer, nullptr );
}

VkFramebufferCreateInfo framebuffer_create_info = {
  VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO,      // VkStructureType                sType
  nullptr,                                        // const void                    *pNext
  0,                                              // VkFramebufferCreateFlags       flags
  Vulkan.RenderPass,                              // VkRenderPass                   renderPass
  1,                                              // uint32_t                       attachmentCount
  &image_view,                                    // const VkImageView             *pAttachments
  GetSwapChain().Extent.width,                    // uint32_t                       width
  GetSwapChain().Extent.height,                   // uint32_t                       height
  1                                               // uint32_t                       layers
};

if( vkCreateFramebuffer( GetDevice(), &framebuffer_create_info, nullptr, &framebuffer ) != VK_SUCCESS ) {
  std::cout << "Could not create a framebuffer!"<< std::endl;
  return false;
}

return true;

18.Tutorial04.cpp,函数 CreateFramebuffer()

创建帧缓冲器时,我们提取当前的交换链扩展,以及已获取交换链图像的图像视图。

接下来开始记录命令缓冲区:

if( !CreateFramebuffer( framebuffer, image_parameters.View ) ) {
  return false;
}

VkCommandBufferBeginInfo command_buffer_begin_info = {
  VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,        // VkStructureType                        sType
  nullptr,                                            // const void                            *pNext
  VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,        // VkCommandBufferUsageFlags              flags
  nullptr                                             // const VkCommandBufferInheritanceInfo  *pInheritanceInfo
};

vkBeginCommandBuffer( command_buffer, &command_buffer_begin_info );

VkImageSubresourceRange image_subresource_range = {
  VK_IMAGE_ASPECT_COLOR_BIT,                          // VkImageAspectFlags                     aspectMask
  0,                                                  // uint32_t                               baseMipLevel
  1,                                                  // uint32_t                               levelCount
  0,                                                  // uint32_t                               baseArrayLayer
  1                                                   // uint32_t                               layerCount
};

if( GetPresentQueue().Handle != GetGraphicsQueue().Handle ) {
  VkImageMemoryBarrier barrier_from_present_to_draw = {
    VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,           // VkStructureType                        sType
    nullptr,                                          // const void                            *pNext
    VK_ACCESS_MEMORY_READ_BIT,                        // VkAccessFlags                          srcAccessMask
    VK_ACCESS_MEMORY_READ_BIT,                        // VkAccessFlags                          dstAccessMask
    VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,                  // VkImageLayout                          oldLayout
    VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,                  // VkImageLayout                          newLayout
    GetPresentQueue().FamilyIndex,                    // uint32_t                               srcQueueFamilyIndex
    GetGraphicsQueue().FamilyIndex,                   // uint32_t                               dstQueueFamilyIndex
    image_parameters.Handle,                          // VkImage                                image
    image_subresource_range                           // VkImageSubresourceRange                subresourceRange
  };
  vkCmdPipelineBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier_from_present_to_draw );
}

VkClearValue clear_value = {
  { 1.0f, 0.8f, 0.4f, 0.0f },                         // VkClearColorValue                      color
};

VkRenderPassBeginInfo render_pass_begin_info = {
  VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,           // VkStructureType                        sType
  nullptr,                                            // const void                            *pNext
  Vulkan.RenderPass,                                  // VkRenderPass                           renderPass
  framebuffer,                                        // VkFramebuffer                          framebuffer
  {                                                   // VkRect2D                               renderArea
    {                                                 // VkOffset2D                             offset
      0,                                                // int32_t                                x
      0                                                 // int32_t                                y
    },
    GetSwapChain().Extent,                            // VkExtent2D                             extent;
  },
  1,                                                  // uint32_t                               clearValueCount
  &clear_value                                        // const VkClearValue                    *pClearValues
};

vkCmdBeginRenderPass( command_buffer, &render_pass_begin_info, VK_SUBPASS_CONTENTS_INLINE );

19.Tutorial04.cpp,函数 PrepareFrame()

首先定义类型变量 VkCommandBufferBeginInfo,并指定命令缓冲区只能提交一次。 指定 VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT 标记时,不能多次提交特定命令缓冲区。 每次提交后,必须重新设置。 但记录操作重新设置它的原因是 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标记用于命令池创建。

接下来我们定义有关图像内存壁垒的子资源范围。 交换链图像布局过渡在渲染通道中隐式执行,但如果显卡队列和演示队列不同,则必须手动执行队列过渡。

然后启动包含临时帧缓冲器对象的渲染通道。

vkCmdBindPipeline( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, Vulkan.GraphicsPipeline );

VkViewport viewport = {
  0.0f,                                               // float                                  x
  0.0f,                                               // float                                  y
  static_cast<float>(GetSwapChain().Extent.width),    // float                                  width
  static_cast<float>(GetSwapChain().Extent.height),   // float                                  height
  0.0f,                                               // float                                  minDepth
  1.0f                                                // float                                  maxDepth
};

VkRect2D scissor = {
  {                                                   // VkOffset2D                             offset
    0,                                                  // int32_t                                x
    0                                                   // int32_t                                y
  },
  {                                                   // VkExtent2D                             extent
    GetSwapChain().Extent.width,                        // uint32_t                               width
    GetSwapChain().Extent.height                        // uint32_t                               height
  }
};

vkCmdSetViewport( command_buffer, 0, 1, &viewport );
vkCmdSetScissor( command_buffer, 0, 1, &scissor );

VkDeviceSize offset = 0;
vkCmdBindVertexBuffers( command_buffer, 0, 1, &Vulkan.VertexBuffer.Handle, &offset );

vkCmdDraw( command_buffer, 4, 1, 0, 0 );

vkCmdEndRenderPass( command_buffer );

if( GetGraphicsQueue().Handle != GetPresentQueue().Handle ) {
  VkImageMemoryBarrier barrier_from_draw_to_present = {
    VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,           // VkStructureType                        sType
    nullptr,                                          // const void                            *pNext
    VK_ACCESS_MEMORY_READ_BIT,                        // VkAccessFlags                          srcAccessMask
    VK_ACCESS_MEMORY_READ_BIT,                        // VkAccessFlags                          dstAccessMask
    VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,                  // VkImageLayout                          oldLayout
    VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,                  // VkImageLayout                          newLayout
    GetGraphicsQueue().FamilyIndex,                   // uint32_t                               srcQueueFamilyIndex
    GetPresentQueue().FamilyIndex,                    // uint32_t                               dstQueueFamilyIndex
    image_parameters.Handle,                          // VkImage                                image
    image_subresource_range                           // VkImageSubresourceRange                subresourceRange
  };
  vkCmdPipelineBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier_from_draw_to_present );
}

if( vkEndCommandBuffer( command_buffer ) != VK_SUCCESS ) {
  std::cout << "Could not record command buffer!"<< std::endl;
  return false;
}
return true;

20.Tutorial04.cpp,函数 PrepareFrame()

接下来我们绑定图像管道。 它包含两个标记为动态的状态:视口和 scissor 测试。 因此我们准备能够定义视口和 scissor 测试参数的结构。 通过调用 vkCmdSetViewport()函数设置动态视口。 通过调用 vkCmdSetScissor()函数设置动态 scissor 测试。 这样图像管道可用于渲染大小不同的图像。

进行绘制之前的最后一件事是绑定相应的顶点缓冲区,为顶点属性提供缓冲区数据。 此操作通过调用 vkCmdBindVertexBuffers()函数完成。 我们指定一个绑定号码(哪个顶点属性集从该缓冲区提取数据)、一个缓冲区句柄指示器(或较多句柄,如果希望绑定多个绑定的缓冲区),以及偏移。 偏移指定应从缓冲区的较远部分提取有关顶点属性的数据。 但指定的偏移不能大于相应缓冲区(该缓冲区未绑定内存对象)的范围。

现在我们已经指定了全部所需要素:帧缓冲器、视口和 scissor 测试,以及顶点缓冲区。 我们可以绘制几何图形、完成渲染通道,并结束命令缓冲区。

教程 04 执行

以下是渲染操作的结果:

我们渲染了一个四边形,各个角落的颜色均不相同。 尝试更改窗口的大小;之前的三角形始终保持相同的大小,仅应用窗口右侧和底部的黑色方框变大或变小。 现在,由于是动态视口,因此四边形将随着窗口的变化而变大或变小。

清空

完成渲染后,关闭应用之前,我们需要破坏所有资源。 以下代码负责完成此项操作:

if( GetDevice() != VK_NULL_HANDLE ) {
  vkDeviceWaitIdle( GetDevice() );

  for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) {
    if( Vulkan.RenderingResources[i].Framebuffer != VK_NULL_HANDLE ) {
      vkDestroyFramebuffer( GetDevice(), Vulkan.RenderingResources[i].Framebuffer, nullptr );
    }
    if( Vulkan.RenderingResources[i].CommandBuffer != VK_NULL_HANDLE ) {
      vkFreeCommandBuffers( GetDevice(), Vulkan.CommandPool, 1, &Vulkan.RenderingResources[i].CommandBuffer );
    }
    if( Vulkan.RenderingResources[i].ImageAvailableSemaphore != VK_NULL_HANDLE ) {
      vkDestroySemaphore( GetDevice(), Vulkan.RenderingResources[i].ImageAvailableSemaphore, nullptr );
    }
    if( Vulkan.RenderingResources[i].FinishedRenderingSemaphore != VK_NULL_HANDLE ) {
      vkDestroySemaphore( GetDevice(), Vulkan.RenderingResources[i].FinishedRenderingSemaphore, nullptr );
    }
    if( Vulkan.RenderingResources[i].Fence != VK_NULL_HANDLE ) {
      vkDestroyFence( GetDevice(), Vulkan.RenderingResources[i].Fence, nullptr );
    }
  }

  if( Vulkan.CommandPool != VK_NULL_HANDLE ) {
    vkDestroyCommandPool( GetDevice(), Vulkan.CommandPool, nullptr );
    Vulkan.CommandPool = VK_NULL_HANDLE;
  }

  if( Vulkan.VertexBuffer.Handle != VK_NULL_HANDLE ) {
    vkDestroyBuffer( GetDevice(), Vulkan.VertexBuffer.Handle, nullptr );
    Vulkan.VertexBuffer.Handle = VK_NULL_HANDLE;
  }

  if( Vulkan.VertexBuffer.Memory != VK_NULL_HANDLE ) {
    vkFreeMemory( GetDevice(), Vulkan.VertexBuffer.Memory, nullptr );
    Vulkan.VertexBuffer.Memory = VK_NULL_HANDLE;
  }

  if( Vulkan.GraphicsPipeline != VK_NULL_HANDLE ) {
    vkDestroyPipeline( GetDevice(), Vulkan.GraphicsPipeline, nullptr );
    Vulkan.GraphicsPipeline = VK_NULL_HANDLE;
  }

  if( Vulkan.RenderPass != VK_NULL_HANDLE ) {
    vkDestroyRenderPass( GetDevice(), Vulkan.RenderPass, nullptr );
    Vulkan.RenderPass = VK_NULL_HANDLE;
  }
}

21.Tutorial04.cpp,destructor

设备处理完所有提交至阵列的命令后,我们开始破坏所有资源。 资源破坏以相反的顺序进行。 首先破坏所有渲染资源:帧缓冲器、命令缓冲区、旗语和栅栏。 栅栏通过调用 vkDestroyFence()函数破坏。 然后破坏命令池。 之后通过调用 vkDestroyBuffer()函数和 vkFreeMemory()函数分别破坏缓冲区和空闲内存对象。 最后破坏管道对象和渲染通道。

结论

本教程的编写以“03-第一个三角形”教程为基础。 我们通过在图像管道中使用顶点属性,并在记录命令缓冲区期间绑定顶点缓冲区,以此改进渲染过程。 我们介绍了顶点属性的数量和布局, 针对视口和 scissors 测试引入了动态管道状态, 并学习了如何创建缓冲区和内存对象,以及如何相互绑定。 我们还映射了内存,并将数据从 CPU 上传至 GPU。

我们创建了渲染资源集,以高效记录和发布渲染命令。 这些资源包括命令缓冲器、旗语、栅栏和帧缓冲器。 我们学习了如何使用栅栏,如何设置动态管道状态的值,以及如何在记录命令缓冲区期间绑定顶点缓冲区(顶点属性数据源)。

下节教程将介绍分期资源。 它们是用于在 CPU 和 GPU 之间拷贝数据的中间缓冲区。 这样应用无需映射用于渲染的缓冲区(或图像),它们也不必绑定至设备的本地(快速)内存。

没有任何秘密的 API:Vulkan* 简介第 0 部分:前言

$
0
0

下载  [PDF 456K]

Github 示例代码链接

关于作者

我成为软件开发人员已有超过 9 年的时间。 我最感兴趣的领域是图形编程,大部分工作主要涉及 3D 图形。 我在 OpenGL* 和着色语言(主要是 GLSL 和 Cg)方面拥有丰富的经验。三年来我还一直致力于开发 Unity* 软件。 我也曾有机会投身于涉及头盔式显示器(比如 Oculus Rift*) 或类似 CAVE 系统的 VR 项目。

最近,我正与英特尔的团队成员一起准备验证工具,为被称为 Vulkan 的新兴 API 提供显卡驱动程序支持。 图形编程接口及其使用方法对我来说非常新鲜。 在了解这些内容的时候我突然想到,我可以同时准备有关使用 Vulkan 编写应用的教程。 我可以像那些了解 OpenGL 并希望“迁移”至其后续产品的人一样,分享我的想法和经验。

关于 Vulkan

Vulkan 被视作是 OpenGL 的后续产品。 它是一种多平台 API,可支持开发人员准备游戏、CAD 工具、性能基准测试等高性能图形应用。 它可在不同的操作系统(比如 Windows*、Linux* 或 Android*)上使用。 Vulkan 由科纳斯组织创建和维护。 Vulkan 与 OpenGL 之间还有其他相似之处,包括图形管道阶段、OpenGL 着色器(一定程度上),或命名。

但也存在许多差异,但这进一步验证了新 API 的必要性。 20 多年来,OpenGL 一直处于不断变化之中。 自 90 年代以来,计算机行业发生了巨大的变化,尤其是显卡架构领域。 OpenGL 库非常适用,但仅依靠添加新功能以匹配新显卡功能并不能解决一切问题。 有时需要完全重新设计。 因此创建出了 Vulkan。

Vulkan 基于 Mantle* — 第一个全新的低级别图形 API。 Mantle 由 AMD 开发而成,专为 Radeon 卡架构而设计。 尽管是第一个公开发布的 API,但使用 Mantle 的游戏和基准测试均显著提升了性能。 后来陆续发布了其他低级别 API,比如 Microsoft 的 DirectX* 12、Apple 的 Metal*,以及现在的 Vulkan。

传统图形 API 和全新低级别 API 之间有何区别? OpenGL 等高级别 API 使用起来非常简单。 开发人员只需声明操作内容和操作方式,剩下的都由驱动程序来完成。 驱动程序检查开发人员是否正确使用 API 调用、是否传递了正确的参数,以及是否充分准备了状态。 如果出现问题,将提供反馈。 为实现其易用性,许多任务必须由驱动程序在“后台”执行。

在低级别 API 中,开发人员需要负责完成大部分任务。 他们需要符合严格的编程和使用规则,还必须编写大量代码。 但这种做法是合理的。 开发人员知道他们的操作内容和希望实现的目的。 但驱动程序不知道,因此使用传统 API 时,驱动程序必须完成更多工作,以便程序正常运行。 采用 Vulkan 等 API 可避免这些额外的工作。 因此 DirectX 12、Metal 或 Vulkan 也被称为精简驱动程序/精简 API。 大部分时候它们仅将用户请求传输至硬件,仅提供硬件的精简抽象层。 为显著提升性能,驱动程序几乎不执行任何操作。

低级别 API 要求应用完成更多工作。 但这种工作是不可避免的, 必须要有人去完成。 因此由开发人员去完成更加合理,因为他们知道如何将工作分成独立的线程,图像何时成为渲染对象(颜色附件)或用作纹理/采样器等等。 开发人员知道管道处于何种状态,或哪些顶点属性变化的更频繁。 这样有助于提高显卡硬件的使用效率。 最重要的原因是它行之有效。 我们能够观察到显著的性能提升。

但“能够”一词非常重要。 它要求完成其他工作,但同时也是一种合适的方法。 在有一些场景中,我们将观察到,OpenGL 和 Vulkan 之间在性能方面没有任何差别。 如果不需要多线程化,或应用不是 CPU 密集型(渲染的场景不太复杂),使用 OpenGL 即可,而且使用 Vulkan 不会实现性能提升(但可能会降低功耗,这对移动设备至关重要)。 但如果我们想最大限度地发挥图形硬件的功能,Vulkan 将是最佳选择。

主要显卡引擎迟早会支持部分(如果不是所有)新的低级别 API。 如果希望使用 Vulkan 或其他 API,无需从头进行编写。 但通常最好对“深层”信息有所了解,因此我准备这一教程。

源代码说明

我是 Windows 开发人员 如果有选择,我选择编写面向 Windows 的应用。 因为我在其他操作系统方面没有任何经验。 但 Vulkan 是多平台 API,而且我希望展示它可用于不同的操作系统。 因此我们准备了一个示例项目,可在 Windows 和 Linux 上编译和执行。

关于本教程的源代码,请访问:

https://github.com/GameTechDev/IntroductionToVulkan

我曾尝试编写尽可能简单的代码示例,而且代码中不会掺杂不必要的“#ifdefs”。 但有时不可避免(比如在窗口创建和管理过程中),因此我们决定将代码分成几个小部分:

  • Tutorial文件,是这里最重要的一部分。 与 Vulkan 相关的所有代码都可放置在该文件中。 每节课都放在一个标头/源配对中。
  • OperatingSystem标头和源文件,包含依赖于操作系统的代码部分,比如窗口创建、消息处理和渲染循环。 这些文件包含面向 Linux 和 Windows 的代码,不过我试着尽可能地保持统一。
  • main.cpp文件,每节课的起点。 由于它使用自定义 Window 类,因此不包含任何特定于操作系统的代码。
  • VulkanCommon标头/源文件,包含面向从教程 3 之后各教程的基本课程。 该类基本上重复教程 1 和 2 — 创建 Vulkan 实例和渲染图像和其他所需的资源,以在屏幕上显示图像。 我提取了这一准备代码,因此其他章节的代码可以仅专注于所介绍的主题。
  • 工具,包含其他实用程序函数和类,比如读取二进制文件内容的函数,或用于自动破坏对象的包装程序类。

每个章节的代码都放置在单独的文件夹中。 有时可包含其他数据目录,其中放置了用于某特定章节的资源,比如着色器或纹理。 数据文件夹应拷贝至包含可执行文件的相同目录。 默认情况下可执行文件将编译成构建文件夹。

没错。 编译和构建文件夹。 由于示例项目可在 Windows 和 Linux 上轻松维护,因此我决定使用 CMakeLists.txt 文件和 CMake 工具。 Windows 上有一个 build.bat 文件,可创建 Visual Studio* 解决方案 — (默认情况下)Microsoft Visual Studio 2013 需要编译 Windows 上的代码。 我在 Linux 上提供了一个 build.sh 脚本,可使用 make 编译代码,但使用 Qt 等工具也可轻松打开 CMakeLists.txt。当然还需要 CMake。

生成解决方案与项目文件,而且可执行文件将编译至构建文件夹。 该文件夹也是默认的工作目录,因此数据文件夹应拷贝至该目录,以便课程正常运行。 执行过程中如果出现问题,cmd/terminal 中将“打印”其他信息。 如果出现问题,将通过命令行/终端运行课程,或检查控制台/终端窗口,以查看是否显示了消息。

我希望这些说明能够帮助大家了解并跟上 Vulkan 教程的节奏。 现在我们来重点学习 Vulkan!


请前往: 没有任何秘密的 API: Vulkan* 简介第 1 部分: 序言


声明

本文件不构成对任何知识产权的授权,包括明示的、暗示的,也无论是基于禁止反言的原则或其他。

英特尔明确拒绝所有明确或隐含的担保,包括但不限于对于适销性、特定用途适用性和不侵犯任何权利的隐含担保,以及任何对于履约习惯、交易习惯或贸易惯例的担保。

本文包含尚处于开发阶段的产品、服务和/或流程的信息。 此处提供的信息可随时改变而毋需通知。 联系您的英特尔代表,了解最新的预测、时间表、规格和路线图。

本文件所描述的产品和服务可能包含使其与宣称的规格不符的设计缺陷或失误。 英特尔提供最新的勘误表备索。

如欲获取本文提及的带订购编号的文档副本,可致电 1-800-548-4725,或访问 www.intel.com/design/literature.htm

该示例源代码根据英特尔示例源代码许可协议发布。

英特尔和 Intel 标识是英特尔在美国和/或其他国家的商标。

*其他的名称和品牌可能是其他所有者的资产。

英特尔公司 © 2016 年版权所有。

GPU Detect

$
0
0

下载代码样本

Microsoft Windows* SDK May 2010 或较新版本(兼容 2010 年 6 月 DirectX SDK)GPU Detect

英特尔公司


特性/描述

日期: 2016 年 5 月 5 日

GPU Detect 是一种简短的示例,演示了检测系统中主要显卡硬件(包括第六代智能英特尔® 酷睿™ 处理器产品家族)的方式。 代码下载包括文档,旨在用作指南,且应该根据游戏的特定需求进行调整。

系统要求

硬件:

  • CPU: 支持的英特尔® CPU
  • GFX:在 Microsoft DirectX* 10(或更高版本)硬件上使用 Microsoft DirectX* 10 显卡 API
  • 操作系统: Microsoft Windows Vista、Windows* 7 SP1 或较新版本

软件:

支持的工具套件:

  • Microsoft Windows* SDK November 2015 或较新版本

支持的编译器:

  • Microsoft Visual Studio* 2010
  • Microsoft Visual Studio* 2013
  • Microsoft Visual Studio* 2015

关联组件

什么是英特尔® Edison 模块?

$
0
0

英特尔® Edison 模块是一种 SD 卡大小的微型计算芯片,专为构建物联网 (IoT) 和可穿戴计算产品而设计。 Edison 模块内含一个高速的双核处理单元、集成 Wi-Fi*、蓝牙* 低能耗、存储和内存、以及用于同用户系统进行交互的广泛输入/输出 (I/O) 选件。 Edison 模块占用空间小、功耗低,是需要强大处理动力但无法连接电源的项目的理想之选。

Edison 模块可嵌入到设备或开发板中,以获取连接和电源。 为帮助用户快速使用该模块,英特尔® 提供了面向 Arduino* 的英特尔® Edison 套件英特尔® Edison Breakout 开发板套件*,可助您加速构建原型。 对于生产部署,您还可以创建自定义开发板。

借助面向 Arduino* 的英特尔® Edison 套件,您可以在广泛使用的 Arduino 软件开发环境中使用开源硬件快速、轻松地构建原型。 该套件允许您扩展 Edison 模块以连接现有的 Arduino UNO R3 Shield,从而扩展功能。 英特尔® Edison Breakout 开发板套件主要提供了电源和 USB 连接选件;例如,您可以将 Edison 开发板连接至笔记本电脑的 USB 端口并快速启动。

英特尔® Edison 模块概览

图 1 显示了 Edison 模块的结构图。


图 1. 英特尔® Edison 模块的结构图

 

[资料来源: http://download.intel.com/support/edison/sb/edisonmodule_hg_331189004.pdf]

该模块包括一颗时钟频率为 500 MHz 的英特尔® 凌动™ 处理器和 4GB 托管闪存。 默认情况下,Yocto Linux* 操作系统安装在闪存中。

对于 Wi-Fi 和蓝牙低能耗连接,该模块包含一个 Broadcom BCM43340 网卡,支持标准的双频带 2.4 GHz 和 5 GHz IEEE 802.11 a/b/g/n 标准、以及 Wi-Fi 保护性接入 (WPA) 和 WPA2(个人),因此可提供强大的加密和身份验证功能。 该连接选项支持以标准化方式更轻松地将 Edison 模块嵌入式设备连接至现有的 Wi-Fi 基础设施。 蓝牙低能耗支持 Edison 设备连接其他蓝牙低能耗设备,例如智能手机,以便智能手机可用作连接互联网的网关。
物联网产品的连接选项是设计物联网产品如何连接至互联网世界时的一个重要考虑因素。 Edison 模块支持两种使用最广泛的连接选项,可帮助用户能轻松地推出实际产品。 Edison 模块通过 Hirose 70 针 DF40 系列连接器与用户系统交互,其中 40 针专用于通用 I/O (GPIO)。

Edison 模块提供了一套可靠而独特的功能,包括小外形、高速双核处理器、低功耗用例、标准连接选项和广泛的 I/O 支持等。 这些特性能够支持构建创新型互联解决方案的各种用例。

英特尔® Edison 模块编程

为 Edison 模块编程时,可使用 C、C++、Python* 或 JavaScript* (Node.js*) 编程语言。 在 Edison 开发板或设备上开发和调试设备代码时, 可根据编程环境下载集成开发环境 (IDE)。 例如,您可以下载适用于 JavaScript 的英特尔® XDK、适用于 C/C++的英特尔® System Studio IoT Edition、适用于 Java 的英特尔® System Studio IoT Edition、或支持为 Edison 开发板和 Arduino 编程的 Arduino IDE。 IDE 的选择取决于项目及其设备要求,以及您用来与设备交互的编程语言。

英特尔提供 Libmraa* 库,以支持与 Edison 设备(或任何受支持的设备)上的传感器和致动器进行交互。 Libmraa 在支持的硬件顶部提供一个抽象层,以便您以标准方式读取传感器和致动器的数据,并创建适用于支持平台的便携式代码。 如欲查阅不同制造商生产的适用于 Edison 设备的传感器和制动器,请浏览 GitHub* 的有用软件包和模块 (UPM) 传感器/制动器资源库 (https://github.com/intel-iot-devkit/upm)。 UPM 是一个涵盖各种传感器的高级资源库,为使用 Libmraa 库与传感器相集成提供了标准模式。 借助广泛使用的编程语言选项以及涵盖各种传感器项目的社区,您可以重新使用现有的编程知识来开发互联产品,并使用 Libmraa 库与面向 I/O 功能的 GPIO 针轻松进行交互。

将 Edison 设备连接至云平台

基于物联网解决方案,您必须将 Edison 设备连接至云平台,以便对传感器数据进行进一步计算和高级分析。 Edison 设备能够为连接至领先云平台提供无缝支持,例如 Microsoft Azure*IBM Watson* 物联网平台、或 Amazon Web Services* (AWS*)等。

这些云平台通常提供使用 C++、Python 或 JavaScript 的软件开发套件 (SDK) 或设备 SDK,能够更轻松地连接 Edison 设备(或任意相关设备)。 典型的开发流程是先读取设备的传感器数据,然后通过受支持的协议,例如 SDK 库的消息队列遥测传输 (MQTT) 或高级消息队列协议 (AMQP),将传感器数据传输至云平台。 请点击以下链接,了解如何将 Edison 设备连接至云平台的详情:

如要快速着手构建物联网应用,您还可以购买包括 Edison 开发板且预安装云平台连接选项的入门套件。 如欲了解详情,请点击以下链接:

您将开发哪些创意产品?

Edison 模块将为您构建面向消费者和工业用例的互联产品提供无限机遇:

  • 消费者用例。 用例包括将 Edison 模块嵌入到手表或健康设备等可穿戴设备中,以跟踪各种健康和生活方式参数,或嵌入到家用自动化设备中以控制娱乐设备或智能地利用能源。
  • 终端分析。 借助高速的双核处理器和低功耗,Edison 模块可嵌入到工业设备中,以提供本地分析和计算支持。 用例包括在设备上本地运行分析或算法,以根据实际条件维护机械设备,以及通过图片分析和对象识别发送告警,以监视并确保智能建筑的安全。

有关创客还能够使用这个微型的创新模块构建的其他项目的信息,请参阅:

总结

本文重点介绍了英特尔® Edison 模块及其硬件规格与核心特性集,这些特性可为创客构建互联产品提供前所未有的机遇。 此外,本文还介绍了 Edison 模块支持的编程语言、可用的 IDE 以及可帮助快速开发和部署 Edison 设备的 Libmraa 库。 最后,本文还介绍了如何将 Edison 设备连接至云平台以及发现的 Edison 技术用例。 Edison 模块具备诸多功能,可助您充分发挥想象力,打造无限可能。


了解物联网生态系统

$
0
0

物联网生态系统剖析

可穿戴设备和家庭自动化设备当今主宰着物联网市场,但是物联网的整个生态系统将不断向前演进。 图 1 展示了物联网生态系统简图:

  • 左侧是终端设备。 它们是物联网的终端,提供了通过传感器和致动器感知和控制环境的途径。
  • 网关收集来自终端设备的数据,然后传输给云(同时通过云提供控制)。 在一些情况下,网关可以处理数据,以增加生态系统的价值。
  • 云提供了存储数据和执行分析的途径。 云的重要性在于:它是一组能够灵活按需扩展或收缩的资源。
  • 云通过应用编程接口和应用(可能位于云中,也可能不位于云中)帮助控制和实现数据的价值。
  • 最顶层是跨生态系统所有层级的管理和监控。
  • 底层是用于支持开发、测试和端到端安全性(针对数据和控制层面)等其他关键功能的技术。

图 1. 物联网生态系统简图。

现在让我们分别看一下物联网生态系统的各个部分以及所用到的技术。

终端

物联网生态系统的终端是联网设备,它们能够以不同的复杂程度感知和启动。 例如在可穿戴设备领域,您可以发现包含生物传感功能的智能腕带和手表。 再比如汽车领域的智能设备网络,它们共同创造了一种更安全和更愉快的驾驶体验(通过传感器提高动力传动系统效率或或者根据海拔或温度调整汽车参数)。

在低功耗可穿戴设备领域,有像英特尔® Quark™ 系统芯片这样依靠硬币大小电池运行,并包含六轴组合传感器(加速计和陀螺仪)的处理器(在极小的 Intel® Curie™ 计算模块中)。 为提高处理能力,英特尔® Edison 计算模块支持单核和双核英特尔® 凌动™ CPU。 英特尔® Edison 主板可运行 Yocto Linux*,其庞大的软件生态系统可带来无数开发机会(请见图 2)。

图 2.英特尔® Curie™ 计算机模块和英特尔® Edison 开发板

网关

当我们谈论物联网时,重点总是放在它所连接的大量 事物上。 出于这一原因,网关是物联网生态系统必不可少的一部分,它将可能不具备任何智能的小型终端设备连接到云(在这里数据实现盈利)。 网关可以担当一项或两项主要功能(有时候同时担当两项功能): 它可以作为桥梁将收集的终端数据迁移到云(并通过云提供控制),还可以作为数据处理器使用,在数据迁移到云的过程中减少可用数据的量或者根据可用数据快速制定决策。 因此,网关往往比终端设备功能更强大。

英特尔® 物联网网关是一款用于物联网网关应用开发的平台(请见图 3)。 该平台集成了多种关键通信技术(包括以太网、Wi-Fi、蓝牙* 和 ZigBee* 以及 2G、3G 和长期演进技术)和传感器/致动器接口(RS-232、模拟/数字输入/输出),拥有从单核英特尔® Quark 系统芯片到双核和四核英特尔® 凌动™ 与英特尔® 酷睿™ 处理器的处理能力。 为简化开发,英特尔物联网网关可支持 Wind River Linux* 7、Windows® 10 或 Snappy Ubuntu* Core(配备面向各种界面的集成式驱动程序支持,让您可以专注于应用开发)

图 3. 英特尔® 物联网网关平台

您可以使用 Wind River* Intelligent Device Platform XT 进一步简化开发工作。 Intelligent Device Platform XT 是一个可定制的中间件开发环境,可在其他事物中间提供安全和管理技术。 尽管这些特性通常都是作为事后补充手段开发,但是在一开始就注重安全性和可管理性问题有利于打造一款出色的物联网网关,从而有效保护您的数据和最大限度地减少停机。

鉴于其出色的可扩展性和灵活性,云是物联网生态系统必不可少的一部分。 随着来自终端设备的数据增多,扩展存储和网络资产以及计算资源的能力成为物联网系统开发的一个重要推动因素。

这项帮助实现灵活计算的技术称作 虚拟化。 借助虚拟化,您可以将处理器划分为两个或更多虚拟处理器。 每个虚拟处理器分时共享物理处理器,当一个处理器需要较少的计算能力时,另一个虚拟处理器(以及占用处理器的软件)可利用这些物理资源。

虚拟化出现已有一段时间,但是您可以发现现代处理器中的扩展正在使该项技术变得更加高效。 正如您所预料的那样,您可能发现虚拟化扩展应用到了面向数据中心的英特尔® 至强® 处理器中,同时它们也应用到了功耗更低的英特尔® 凌动™ 处理器中。

虚拟化意味着当来自终端设备的物联网数据增多时,物理处理器将被划分为若干虚拟处理器来处理这些数据流。 当数据流减少时,这些资源将被闲置或重新分配给其他任务以节省电源和成本。

管理和监控

物联网产生的一个复杂问题是对网关和终端设备的监控和管理。 由于一个物联网系统可能包含数千个网关,而这些网关又连接了数百万个传感器和致动器终端,管理和监控工作面临着新的挑战。

尽管可以构建一个基于云的自定义应用集来应对挑战,但是您还必须考虑上市时间限制。 这就是 Wind River 创建 Wind River Helix* Device Cloud 的原因之一。 Device Cloud 是一个基于云的物联网平台,可提供设备管理、端到端安全性以及遥测和分析。 它是一个运行在终端设备和云之间的技术堆栈,提供数据捕获、数据分析和对物联网系统的整体监控和管理功能。 Device Cloud 还全面集成了英特尔® 物联网网关技术以及 Wind River Linux 和 VxWorks* 等一系列操作系统。

分析

物联网最关键的支撑是数据,这是创造价值的所在。 物联网数据格式多样,但是通常都具备两个属性:数据规模以及数据与时间的关系。

物联网部分要通过大数据处理系统来实现。 这些系统专为需要非传统处理手段的数据集而设计。 物联网生态系统中大量终端设备所产生的数据集正适合这些系统。 物联网数据的另一个属性是它往往是时间序列数据。 与传统方法相比,其存储和分析更适合使用大数据处理系统和 NoSQL 数据库。

Apache Hadoop* (通过 Cloudera 提供)仍然是重要的大数据处理系统,它自身包含一个可以满足各种需求的技术生态系统。 以数据流系统 Apache NiFi* 为例,它允许通过定向图进行基于流的编程(非常适合时间序列数据流)。 不同于面向批处理的 Hadoop Distributed File System (HDFS),Apache Cassandra* 是一个分布在节点之间的 NoSQL 数据库,可支持分布在不同地理位置数据中心内的集群。 Cassandra 数据模型也适合对时间序列数据的实时处理(使用混合键值和面向列的数据库)。 图 4 演示了这些组件之间的关系。

图 4.大数据处理系统与文件系统之间的关系

云是进行数据分析的理想平台。 将计算资源作为数据集规模函数扩展的能力或处理速度要求使得云成为利用 NiFi 等系统分析物联网数据的理想平台。 当需要处理数据集时,云能够支持灵活扩展计算能力,并可在不需要时再减少这些资源,从而最大限度地降低了基础设施成本。

支持技术

物联网生态系统还使用了一些其他非常重要的技术。 让我们重点看一些开发和测试技术以及物联网生态系统内的设备所采用的一些技术:

  • Wind River Helix App Cloud 是一个面向物联网应用的基于浏览器的开发环境。 借助 App Cloud,您可以开发代码、进一步构建 Wind River 操作系统和使用 Edison 开发板等设备简化应用测试。 由于它是一个基于浏览器的开发环境,您可以利用一流集成开发环境能够提供的所有功能,随时随地连接到开发环境。
  • Wind River Helix Lab Cloud 全面集成 App Cloud,支持对各种虚拟化设备上的应用进行广泛测试。 通过 Lab Cloud,您可以创建一个代表物理设备的设备配置,然后在云中对设备进行虚拟化。 借助 App Cloud,您可以将代码加载到设备上进行确认。 作为一组虚拟化资源,您可以创建数千台设备进行测试,从而帮助您更快地发现漏洞。 Lab Cloud 可帮助您在终端设备或网关上创建可靠的物联网应用。
  • Wind River Rocket* 是一款专为物联网设计的卓越的实时操作系统 (RTOS),它使用了诸如英特尔® Edison™ 开发板这样的硬件。 Rocket 具有出色的可扩展性,仅占用 4 KB 内存,是电源和内存受限系统的理想选择。 Rocket 提供了包括多线程在内的 RTOS 能够提供的所有服务,而且还预集成 App Cloud,可在最短时间内轻松构建网关或终端设备应用。
  • Wind River Pulsar* Linux 是一个Linux 分发版,专用于需要安全性和可管理性的小型、高性能物联网系统。 Pulsar 支持重新配置内核,让您可以根据需求量身定制,纳入虚拟化等功能以构建复杂的物联网应用。 您还可以利用持续更新来确保平台的可靠性和安全性。 您可以在各种硬件解决方案上使用 Pulsar,如 MinnowBoard MAX* 主板和英特尔® 凌动™ CPU 等。

总结

物联网生态系统可通过广泛的技术集合创建,但是其共同主线始终是可管理性和安全性。 构建一款端到端的物联网平台需要您具备多个学科的实践知识,但是通过利用协作运行的预验证和预集成资产,这项工作不仅变得更简单,而且也会变得更有趣。

管理锁争用: 大、小关键代码段

$
0
0

管理锁争用 — 大、小关键代码段 (PDF 147KB)

摘要

在多线程应用中,程序员会使用锁来同步线程进入可访问共享资源的代码区域的行为。 受这些锁保护的代码区域被称为关键代码段 (Critical Section)。 如果关键代码段中已存在一个线程,那么其它任何线程都不可进入该代码段。 由此可见,关键代码段采用序列化执行方式。 本文介绍了关键代码段大小这一概念及其对性能的影响。关键代码段大小指线程在关键代码段中花费的时间长度。

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

背景

关键代码段可在多个线程尝试访问共享资源时确保数据的完整性。 它们还对自身内部的代码执行进行了序列化。 线程应尽量缩短在关键代码段中花费的时间,进而减少其它线程在代码段外闲置等待获得锁的时间 — 这种状态被称之为“锁争用”。 换句话说,关键代码段越小越好。 然而,使用大量独立的小代码段会导致与获取和释放各个独立锁相关的系统开销。 本文中描述的情景阐明了什么时候最适合使用大型或小型关键代码段。

代码示例 1 中的线程函数包含两个关键代码段。 假设这两个关键代码段可保护不同数据,并且函数 DoFunc1 和 DoFunc2 中的工作是相互独立的。 与此同时,假设执行上述两个更新函数中的任意一个所花费的时间都非常短。

代码示例 1:

 

Begin Thread Function ()

	Initialize ()


	BEGIN CRITICAL SECTION 1

	UpdateSharedData1 ()

	END CRITICAL SECTION 1


	DoFunc1 ()


	BEGIN CRITICAL SECTION 2

	UpdateSharedData2 ()

	END CRITICAL SECTION 2


	DoFunc2 ()

	End Thread Function ()


	

 

关键代码段被一个 DoFunc1调用请求分离开来。 如果线程在 DoFunc1函数上只花费了很短的时间,那么同步两个关键代码段所产生的开销没有任何实际意义。 在这种情况下,更好的方案是将两个小关键代码段合并为一个稍大的关键代码段,如代码示例 2。

代码示例 2:

 

Begin Thread Function ()

	Initialize ()


	BEGIN CRITICAL SECTION 1

	UpdateSharedData1 ()

	DoFunc1 ()

	UpdateSharedData2 ()

	END CRITICAL SECTION 1


	DoFunc2 ()

	End Thread Function ()

	

 

如果花费在DoFunc1函数上的时间远远超过执行两个更新例程的总时间,那么该方案可能不可行。 增加的关键代码段大小可提高出现锁争用现象的可能性,而且线程数量越多越是如此。

现在让我们假设上一示例中的情况稍有改变,线程在UpdateSharedData2函数上会花费较长时间,那么结果又会如何呢? 此时,使用单个关键代码段同步到 UpdateSharedData1UpdateSharedData2的访问(如代码示例 2)不再是适当的解决方案,因为出现锁争用现象的几率增加了。 执行过程中,获得关键代码段访问权的线程将在代码段中花费非常长的时间,导致所有其它线程全部堵塞在外。 当占用锁的线程将锁释放后,正等待的线程中只有一条可进入关键代码段,所有其它线程还要堵塞很长一段时间。 因此,在这种情况下,代码示例 1 反而是更好的选择。

将锁关联到特定共享数据是一项不错的编程实践。 使用同一个锁保护到某共享变量的所有访问并不能阻止其它线程访问由不同锁保护的其它共享变量。 假设使用共享数据结构, 您可以为该结构中的每个元素创建一个独立的锁,或者创建单个锁来保护到整个结构的访问。 考虑到更新元素的计算成本,这两种极端的方法都有可能是切实可行的解决方案。 不过,最佳锁粒度也可能处于在这两者之间。 例如,在某个指定的共享数组中,可以创建两个锁:一个用于保护偶数编号的元素,另一个则用于保护奇数编号的元素。

如果执行 UpdateSharedData2函数需要较长时间,最佳方案是按照该例程划分工作,并创建新的关键代码段。 在代码示例 3 中,原始的UpdateSharedData2函数被分解为两个使用不同数据进行运算的函数。 这样做的原因是希望通过使用分离的关键代码段来减少锁争用。 如果 UpdateSharedData2的整个执行过程都不需要保护,您应该考虑在函数中需要访问共享数据的点插入关键代码段,而不是封闭整个函数调用。

代码示例 3:

 

Begin Thread Function ()

	Initialize ()


	BEGIN CRITICAL SECTION 1

	UpdateSharedData1 ()

	END CRITICAL SECTION 1


	DoFunc1 ()


	BEGIN CRITICAL SECTION 2

	UpdateSharedData2 ()

	END CRITICAL SECTION 2


	BEGIN CRITICAL SECTION 3

	UpdateSharedData3 ()

	END CRITICAL SECTION 3


	DoFunc2 ()

	End Thread Function ()

	

 

建议

根据获取和释放锁的开销调整关键代码段的大小。 考虑整合小关键代码段,以分担锁定开销。 将锁争用现象严重的大型关键代码段划分为较小的关键代码段。 将锁关联至特定的共享数据,借以最大限度减少锁争用问题。 最佳解决方案可能处于为每个共享数据元素创建一个锁和为所有共享数据创建一个锁两种极端之间。

切记,同步操作会将执行序列化。 采用大关键代码段意味着算法本身的并发性非常低,或者线程间的数据划分并不理想。 对于前者,只能更改算法。 对于后者,可尝试为共享数据创建本地拷贝,支持线程异步访问。

之前对关键代码段大小和锁粒度的讨论并没有将环境切换所带来的影响考虑在内。 当线程堵塞在关键代码段之外,等待获取锁时,操作系统将使用活动线程交换闲置线程, 这便是所谓的环境切换。 一般来说,该行为很有用,可释放 CPU 以执行更重要的任务。 然而,对于正等待进入小关键代码段的线程来说,使用旋转等待 (spin-wait) 循环可能比环境切换操作更为有效。 但是,鉴于处于等待状态的线程在旋转等待循环中仍将占用 CPU 资源, 只有当线程在关键代码段中所花费的时间极短,不良影响低于环境切换时,才推荐使用旋转等待循环。

代码示例 4 展示了使用 Win32 线程 API 时可采用的一种较为有效的试探法。 该示例针对 Win32 CRITICAL_SECTION 对象使用了旋转等待选项。 无法进入关键代码段的线程将自旋,而不是释放 CPU 资源。 如果在旋转等待过程中 CRITICAL_SECTION 变为可用,便可避免环境切换。 自旋计数参数决定了线程在进入堵塞状态前将旋转的次数。 在单处理器系统中,自旋计数参数将被忽略。 代码示例 4 将应用中所有线程的自旋计数均设定为 1000,而允许的最大自旋计数是 8000。

代码示例 4:

 

int gNumThreads;

	CRITICAL_SECTION gCs;


	int main ()

	{

	int spinCount = 0;

	...

	spinCount = gNumThreads * 1000;

	if (spinCount > 8000) spinCount = 8000;

	InitializeCriticalSectionAndSpinCount (&gCs, spinCount);

	...

	}


	DWORD WINAPI ThreadFunc (void *data)

	{

	...

	EnterCriticalSection (&gCs);

	...

	LeaveCriticalSection (&gCs);

	}

	

 

使用指南

在支持英特尔® 超线程技术(英特尔® HT 技术)的处理器中,需要对代码示例 4 中使用的自旋计数参数进行单独调整,因为旋转等待循环通常会对此类处理器的性能造成极大影响。 与具备多个物理 CPU 的真正对称多处理器 (SMP) 系统不同,英特尔 HT 技术可在同一 CPU 核心上创建两路逻辑处理器。 旋转线程和正执行有用任务的线程务必会争夺逻辑处理器资源。 显而易见,与对称多处理器系统相比,旋转线程对采用英特尔超线程技术的系统中多线程应用性能的影响更大。 在这种情况下,应将代码示例 4 中的自旋计数调低,或者根本不采用旋转等待循环。

更多资源

使用线程化 API 提供的同步例程,而非手工编写同步例程

$
0
0

使用线程化 API 提供的同步例程,而非手工编写同步例程 (PDF 202KB)

摘要

应用编程人员有时候手工编写同步例程而非使用线程 API 提供的结构,以便减少同步开销,或提供不同于现有结构所提供的功能。 遗憾的是,使用手工编写的同步例程可能对性能、性能调谐或多线程应用的调试造成负面影响。

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

背景

通常,编程人员喜欢手工编写同步例程,以避免有时候由线程 API 提供的同步例程产生的相关开销。 编程人员自己编写同步例程的另外一个原因是,线程 API 提供的功能与实际需求不能完全匹配。 遗憾的是,与使用线程 API 例程相比,手工编写同步例程存在严重的缺点。

其中一点是不能确保针对不同的硬件架构与操作系统提供出色的性能。 下面以 C 语言手工编写的自旋锁为例,来帮助说明这些问题:

 

#include


	void acquire_lock (int *lock)

	{

	while (_InterlockedCompareExchange (lock, TRUE, FALSE) == TRUE);

	}


	void release_lock (int *lock)

	{

	*lock = FALSE;

	}

	

 

编译器内部函数 _InterlockedCompareExchange是一种互锁的内存操作,可确保在函数执行期间其它线程不能修改指定的内存位置。 该函数首先将第一个参数中的地址对应的内存内容与第三个参数中的值进行比较,如果匹配,则将第二个参数中的值存储到第一个参数中指定的内存地址。 在指定地址的内存内容中发现的初始值被内部函数返回。 在本例中,acquire_lock例程不停自旋,直到内存位置锁中的内容处于解锁状态(FALSE),此时(通过将锁的内容设置为TRUE)获得锁并例程返回。release_lock例程将内存位置锁的内容重新设为 FALSE,以便释放锁。

尽管乍一看该锁的实施似乎非常简单高效,但是它存在以下几个问题:

  • 如果许多线程在同一个内存位置自旋,在锁被释放时,该点就会出现过多的高速缓存无效和过多的内存流量,结果导致随着线程数量增加扩展能力变差。
  • 该代码使用的原子内存基元可能不适用于所有处理器架构,因而限制了可移植性。
  • 紧密的自旋循环可能导致某些处理器架构特性的性能变差,例如英特尔 ® 超线程技术。
  • while循环对于操作系统来说好像是在执行有用的计算,但是它能对操作系统调度的公平性产生负面影响。

尽管有技术能够解决所有这些问题,但它们通常使代码变得极其复杂,以至于难以验证其正确性。 此外,很难做到代码调谐的同时保持可移植性。 这些问题最好留给线程 API 的作者,因为他们有更多的时间对同步结构进行验证和调谐,以实现出色的可移植性和可扩展性。

手工编写同步例程的另一个严重缺点是,它通常会降低编程工具在线程化环境中的准确性。 例如,英特尔 ® Parallel Studio 工具必须能够识别同步结构,以便提供有关线程化应用程序的性能(使用英特尔 ® Parallel Amplifier)和正确性(使用英特尔 ® Parallel Inspector)的精确信息。

线程工具在设计上通常会考虑发现和区别所支持线程API 提供的同步结构的功能。 如果没有使用标准的同步 API 来实现,这些工具将难以发现和理解同步,如上述示例所示。

有时候,编程人员以工具专用指令、编译指示或 API 调用的形式提供工具支持提示,以便发现和区别手工编写的同步例程。 尽管为特定工具所支持,但与使用线程 API 同步例程相比,这样的提示可能导致应用程序分析准确性降低。出现性能问题的原因可能难以检测,或者线程更正工具可能会报告严重的竞争状态或失去同步。

建议

如果可能,尽量避免使用手工编写的同步例程。 相反,使用您青睐的线程 API 提供的例程,例如面向英特尔 ® 线程构建块的queuing_mutexspin_mutexomp_set_lock/omp_unset_lock,或面向 OpenMP* 的critical/end critical指令,或面向 Pthreads* 的 pthread_mutex_lock/pthread_mutex_unlock。 学习线程 API 同步例程,以便找到一个适合您应用的例程。

如果线程 API 中没有能够提供所需功能的同步例程,可考虑针对程序使用对同步要求不高或要求其它同步的不同算法。 此外,专业的编程人员可以通过简单的 API 同步结构构建一个自定义同步结构,而非从零开始。 如果因为性能原因而必须使用手工编写的同步例程,可以考虑使用预处理指令,以便能够轻松使用与线程 API 功能相当的同步例程替换手工编写的同步例程。

使用指南

编程人员如果通过简单的 API同步结构创建自定义同步结构,应避免在共享位置使用自旋循环,从而避免性能不可扩展。 如果代码必须具备可移植性,还应避免使用原子内存基元。 线程性能和更正工具的准确性可能受到影响,因为这些工具可能无法推论出自定义同步结构的功能,即使构建该结构所使用的简单同步结构能够被正确识别。

更多资源

选择合适的同步基元以最大限度地减少开销

$
0
0

选择合适的同步基元以最大限度地减少开销 (PDF 237KB)

摘要

如果线程在一个同步点等待,那么它们无法做有用功。 然而,多线程程序中通常需要一定程度的同步化,明确的同步有时甚至优于数据复制或复杂的非阻塞调度算法,然而其本身也存在一些问题。 当前市场上存在着大量同步技术,应用程序开发人员应选择适当的技术,从而最大限度地降低整体同步开销。

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

背景

同步本身可构建序列执行,因此限制了并行能力,而且可能降低整体应用性能。 事实上,当前只有很少的多线程程序能够完全避免同步。 但幸运的是,我们可通过选择合适的结构来减少与同步有关的系统开销。 本文将阐述一些可用的解决方案,针对每个解决方案提供示例代码,并列举出它们的主要优缺点。

Win32* 同步 API

Win32 API 提供了几种保护原子性 (atomicity) 的机制,本章节主要讨论其中 3 种。 一个增量语句 (increment statement)(例如 = var++)说明了不同的结构。 如果正在更新的变量在线程之间共享,那么加载→写入→存储指令必须为原子操作(即操作完成之前不能抢占指令序列。) 下面的代码演示了如何使用这些 API。

#include


	CRITICAL_SECTION cs; /* InitializeCriticalSection called in main() */

	HANDLE mtx; /* CreateMutex called in main() */

	static LONG counter = 0;


	void IncrementCounter ()

	{

	// Synchronize with Win32 interlocked function

	InterlockedIncrement (&counter);


	// Synchronize with Win32 critical section

	EnterCriticalSection (&cs);

	counter++;

	LeaveCriticalSection (&cs);


	// Synchronize with Win32 mutex

	WaitForSingleObject (mtx, INFINITE);

	counter++

	ReleaseMutex (mtx);

	}

	

比较这三种机制,进而说明哪种机制在各种同步方案中更为适合。 Win32 互锁函数(InterlockedIncrement、 InterlockedDecrement、InterlockedExchange、InterlockedExchangeAdd、 InterlockedCompareExchange)仅限于简单操作,但它们比关键区域更快。 此外,需要调用的函数更少;进出一个 Win32 关键区域需要调用 EnterCriticalSectionLeaveCriticalSection或者 WaitForSingleObjectReleaseMutex。互锁函数也同样无阻碍,但如果同步对象不可用,那么EnterCriticalSectionWaitForSingleObject(或 WaitForMultipleObjects)将阻碍线程。

如果需要一个关键区域,那么在一个 Win32 CRITICAL_SECTION上实现同步化所需的开销远远低于实现 Win32 mutex、信号量和 eventHANDLEs同步化所需的花费,因为前者是用户空间对象,而后者是内核空间对象。尽管 Win32 关键区比 Win32 mutexes 要快,然而它们并可通用。 尽管 Win32 关键代码段比 Win32 mutexes 要快,然而它们并不通用。 同其它内核对象一样,Mutexes 也可用于流程内同步化。 采用 WaitForSingleObjectWaitForMultipleObjects函数也将产生等待时间。 线程在指定时间期限结束后继续执行,而不是为获取一个互斥体而无限期等待。 将等待时间设置为零,以便线程可无阻碍地测试一个互斥体是否可用。 (请注意,使用 TryEnterCriticalSection函数也可以无阻碍地检测一个 CRITICAL_SECTION是否可用。) 最后,如果线程终止而同时带有一个互斥体,操作系统将会发出信号进行处理,从而防止等待线程成为死锁。 如果线程终止而带有 CRITICAL_SECTION,那么等待进入 CRITICAL_SECTION的线程变为死锁。

当一个 Win32 线程试图获取一个已被另一线程持有的 CRITICAL_SECTION或 mutex HANDLE时,它会立即将 CPU 让与操作系统。 通常来说,这是一个好现象。 线程受到阻碍,CPU 可做有用功。 然而,阻碍和疏通线程的开销较大。 有时,线程在受阻之前试图再次获得锁则更具优势(例如,在 SMP 系统中,在较小的关键代码段)。Win32 CRITICAL_SECTIONs具有一个用户可配置的自旋计数,用以控制放弃 CUP 之前线程的等待时间。 InitializeCriticalSectionAndSpinCountSetCriticalSectionSpinCount函数为试图进入一个特定CRITICAL_SECTION的线程设定自转计数。

建议

针对变量(例如增量、减量、交换量)的简单操作,采用速度更快、开销更低的 Win32 互锁函数。

当流程间需要同步化或时间等待,使用 Win32 mutex,信号量或 event HANDLE。 否则,使用系统费用较低的 Win32 CRITICAL_SECTION

使用InitializeCriticalSectionAndSpinCountSetCriticalSectionSpinCount函数来控制 Win32 CRITICAL_SECTION的自转计数。 在放弃 CPU 之前,控制等待线程自转时间对于低争用和高争用关键代码段尤为重要。 自转计数可显著影响 SMP 系统和采用英特尔® 超线程技术处理器的性能。

英特尔® 线程构建模块同步 API

英特尔® 线程构建模块(英特尔® TBB)针对原子操作提供了便携式包装器(模板类原子<T>)和不同版本的互斥机制,其中包括在一个“原生”互斥体周围的包装器。 鉴于前面已经讨论了采用原子操作和依赖于操作系统的同步化 API 的优势与不足,本章节将跳过tbb::原子<T>tbb::互斥体,而将重点放在快速的用户级同步化类别,例如 spin_mutexqueuing_mutexspin_rw_mutexqueuing_rw_mutex

最简单的互斥为spin_mutex。 线程在获取spin_mutex上的锁之前将会保持等待状态。 当只针对少数指令保留锁时,spin_mutex最为适当。 例如,下面的代码使用一个互斥体 FreeListMutex来保护一个共享的变量FreeList:

Node* FreeList;

	typedef spin_mutex FreeListMutexType;

	FreeListMutexType FreeListMutex;


	Node* AllocateNode()

	{

	Node* n;

	{

	FreeListMutexType::scoped_lock lock(FreeListMutex);

	n = FreeList;

	if( n )

	FreeList = n->next;

	}

	if( !n )

	n = new Node();

	return n;

	}

	

scoped_lock的构造函数将会一直等待,直到 FreeListMutex上没有其它的锁。 析构函数释放锁。 AllocateNode函数内其他的大括号能够尽可能缩短锁的生命周期,以便其它等待的线程尽快获得锁。

英特尔 TBB 提供的另一个用户级自转互斥是 queuing_mutex, 它也是用户级互斥,但与spin_mutex相比,queuing_mutex更为公平。 公平的互斥体让线程有秩序地抵达。 公平互斥避免出现“挨饿”线程,因为每个线程都能轮到。 不公平互斥较公平互斥的速度更为快些,因为它们首先让正在运行的线程通过,而不是按顺序通过,因此部分线程可能会因中断而进入睡眠状态。 如果注重可扩展性和公平性,那么应该采用队列互斥体 (Queuing mutex)。

并非所有共享数据的访问都需要相互排斥。 在大多数实际应用中,对并发数据结构的访问通常是读取访问,只有少部分是写入访问。 对于这样的数据结构,读取者之间的相互排斥是没有必要的,这样的序列是可以避免的。 英特尔 TBB 读/写锁允许多个读取者进入关键区,只有写入者线程能够获得一个排斥访问。 忙碌等待读/写互斥体的不公平版本为spin_rw_mutex,其公平版本为queuing_rw_mutex。 读/写互斥体提供与 spin_mutexqueuing_mutex相同的 scoped_lock API,此外它还提供特殊函数,允许一个读锁升级至一个写锁,或将一个写锁降级至一个读锁。

建议

如果要成功选择合适的同步机制,关键是了解您的应用,其中包括正在处理的数据和处理的方式。

如果关键区域仅有几个指令,并且无需顾及公平性问题,那么应选择 spin_mutex。 如果关键区域空间较小,但需要线程按照抵达顺序访问关键区域,那么应使用 queuing_mutex

如果大多数并发数据访问为读取访问,并且仅有小部分线程需要写入访问数据,则可以使用读/写锁来帮助避免不必要的序列化,从而提高整体应用性能。

使用指南

当连续调用 Win32 互锁函数时,请注意线程抢占问题。 例如,当执行多线程时,下列代码段不会针对局部变量生成相同的值。

static LONG N = 0;

	LONG localVar;

	…

	InterlockedIncrement (&N);

	InterlockedIncrement (&N);

	InterlockedExchange (&localVar, N);


	static LONG N = 0;

	LONG localVar;

	…

	EnterCriticalSection (&lock);

	localVar = (N += 2);

	LeaveCriticalSection (&lock);

例如,若使用互锁函数,那么任意函数调用之间的线程抢占可能会产生无法预期的后果。 关键代码段比较安全,因为原子操作(例如更新全球变量 N 并分配到 localVar)可得到保护。

为确保安全,无论是采用 CRITICAL_SECTION变量还是mutex HANDLE构建的 Win32 关键区域,应仅有一个进出点。 进入关键代码段将使同步化失效。 跳出一个关键代码段而不调用 LeaveCriticalSectionReleaseMutex将使等待线程变为死锁。 单一进出点同样产生清晰代码。

必须防止出现线程在终止时持有CRITICAL_SECTION变量的情况,因为它将会导致等待线程变为死锁。

更多资源

如有可能可使用非阻塞锁

$
0
0

如有可能可使用非阻塞锁 (PDF 191KB)

摘要

通过执行由辅助线程实施提供的同步基元,线程可在共享资源上实现同步。 这些基元例如互斥体 (mutex) 和旗语 (semaphore) 等只允许单条线程持有锁,其它线程则依据自身的超时机制自旋或阻塞。 阻塞线程将导致成本昂贵的环境切换操作,而旋转等待则会浪费 CPU 执行资源(除非等待时间非常短)。 另一方面,非阻塞系统调用则允许未成功获得锁的竞争线程原路返回,继续执行有意义的工作,进而避免浪费执行资源。

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

背景

大多数线程实施,包括 Windows* 和 POSIX* 线程 API,均可提供阻塞和非阻塞两种线程同步基元。 默认情况下通常使用阻塞基元。 成功争得锁之后,线程便获得锁的控制权,进入关键代码段执行代码。 但是,如果没有争得锁,系统便会执行环境切换,线程将被置于等待队列中。 环境切换的成本非常高,需尽量避免,具体原因如下:

  • 环境切换开销特别高,基于内核线程的线程实施尤为如此。
  • 应用中跟随同步调用之后的有用工作必须等线程获得锁后才能够执行。

使用非阻塞系统调用有助减少性能损失。 在这种情况下,应用线程如果没能成功锁定关键代码段,便会继续执行代码。 这不但可以消除环境切换开销,同时也可避免线程在等待获得锁定权的过程中自旋。 事实上,线程在重新尝试争夺锁定权之前会一直执行有用工作。

建议

使用非阻塞线程调用来避免生成环境切换开销。 非阻塞同步调用通常以关键字 try开始。 例如,Windows 线程实施提供的阻塞和非阻塞版本关键代码段同步基元如下所示:

如果线程在争夺锁的过程中成功获得关键代码段的所有权, TryEnterCriticalSection调用将返回“ True”Boolean 值。 否则,它将返回“False”,线程便可以继续执行应用代码。

void EnterCriticalSection (LPCRITICAL_SECTION cs);
bool TryEnterCriticalSection (LPCRITICAL_SECTION cs);

非阻塞系统调用的典型使用示例如下:

CRITICAL_SECTION cs;

	void threadfoo()

	{

	while(TryEnterCriticalSection(&cs) == FALSE)

	{

	// some useful work

	}

	// Critical Section of Code

	LeaveCriticalSection (&cs);

	}

	// other work

	}


	

同样地,POSIX 线程提供非阻塞版本的互斥体 (mutex)、旗语(semaphore) 和条件变量同步基元。 阻塞和非阻塞版本的互斥体同步基元如下所示:

int pthread_mutex_lock (pthread_mutex_t *mutex);
int pthread_mutex_try_lock (pthread_mutex_t *mutex);

在 Windows* 线程实施中,还可以为线程锁定基元设定超时时间。 Win32* API 提供了WaitForSingleObjectWaitForMultipleObjects系统调用,用于在内核对象上实现同步。 执行这些调用的线程将一直等待直至相应的内核对象可用,或者用户指定的时间间隔已过。 一旦超时间隔已过,线程便可继续执行有用工作。
DWORD WaitForSingleObject (HANDLE hHandle, DWORD dwMilliseconds);

在上面的代码中,hHandle 是内核对象的句柄 ( hHandle); dwMilliseconds是超时间隔,如果该间隔过后内核对象仍不可用函数便会自动返回。 “INFINITE”值表示线程将无限期地等待下去。 下方列出了使用该 API 调用的代码片断。

void threadfoo ()

	{

	DWORD ret_value;

	HANDLE hHandle;

	// Some work

	ret_value = WaitForSingleObject (hHandle,0);


	if (ret_value == WAIT_TIME_OUT)

	{

	// Thread could not gain ownership of the kernel

	// object within the time interval;

	// Some useful work

	}

	else if (ret_value == WAIT_OBJECT_0)

	{

	// Critical Section of Code

	}

	else { // Handle Wait Failure}

	// Some work

	}

	

同样地, WaitForMultipleObjectsAPI 调用允许线程等待多个内核对象进入可用状态。

使用非阻塞系统调用如 TryEnterCriticalSection时,在释放共享对象前应查看同步调用的返回值,确保请求已得到满足。

使用指南

现代代码社区

Aaron Cohen 和 Mike Woodring。《Win 32 多线程编程》, O'Reilly Media;第 1 版, 1997 年。

Jim Beveridge 和 Robert Wiener,Win32 多线程应用 — 完整线程指南, Addison-Wesley Professional, 1996 年。

Bil Lewis 和 Daniel J Berg,基于 Pthreads 的多线程编程, Prentice Hall PTR;第 136 版, 1997 年。

整理您的数据和代码: 数据和布局 - 第 2 部分

$
0
0

这两篇关于性能和内存的文章介绍了一些基本概念,用于指导开发人员更好地改善软件性能。为实现此目标,文章内容重点阐述了内存和数据布局方面的注意事项。第 1 部分介绍了寄存器使用以及覆盖或阻塞算法,以改善数据重用情况。文章从考虑数据布局以提供通用并行处理能力(与线程共享内存编程)开始,然后还考虑了基于 MPI 的分布式计算。本文扩展了在实现并行处理能力时需考虑的概念,包括矢量化(单指令多数据 SIMD)、共享内存并行处理(线程化)和分布式内存计算等。最后,文章考虑了数据布局结构阵列 (AOS) 以及阵列结构 (SOA) 数据布局。

第 1 部分强调的基本性能原则是:在寄存器或缓存中重新利用数据然后再将其去除。在本文中强调的性能原则为:在最常用数据的地方放置数据,以连续访问模式放置数据,并避免数据冲突。

 

与线程共享内存编程

让我们从考虑与线程共享内存编程开始。全部线程都共享进程中的相同内存。有许多常用的线程模型。最为闻名的是 Posix* 线程和 Windows* 线程。正确创建和管理线程中涉及的工作容易出错。涉及大量模块和大型开发团队的现代软件让线程的并行编程极易出错。在此过程中开发团队需要开发数个程序包,以简化线程创建、管理和充分利用并行线程。最常用的两个模型为 OpenMP* 和英特尔® 线程构建模块。第三个线程模型英特尔® Cilk™ Plus 还未达到与 OpenMP 和线程构建模块相似的采用级别。所有这些线程模型形成线程池,该线程池被重新用于每个并行操作或并行区域。OpenMP 的优势在于可通过使用指令,逐步提高并行处理能力。OpenMP 指令通常可添加至现有软件,并仅需对每一步骤的过程进行最少的代码变更。它允许使用线程运行时库来管理大量线程维护工作,可显著简化线程软件的开发。同时它还可以为所有代码开发人员提供一个可沿用的一致线程模型,减少一些常见线程错误的可能性,并提供由专注于线程优化的开发人员制作的最优线程化运行时库。

简介段落中提及的基本并行原则为将数据置于使用该数据之处,并避免移动数据。在线程化编程中,默认模型是在进程中全局共享数据,并可由所有线程访问数据。有关线程的简介文章强调了通过将 OpenMP 应用至循环 (Fortran*) 或用于循环 (C) 来开始线程的便利性。当在两到四核上运行时,这些方法通常能够带来速度提升。这些方法会经常地扩展至 64 条线程或更多。但很多时候也不会进行此类扩展。在不进行扩展的一些情况中,主要是因为它们尊虚了良好的数据分解计划。这要求为良好的并行代码设计一个架构。

在代码调用堆栈的较高级别利用并行处理能力非常重要,而不是局限于由开发人员或软件工具确定的并行机会。当开发人员认识到可并行操作任务或数据时,依据埃坶德尔定律考虑这些问题: “在进行这点之前,我是否可以开始更高级别的并行运算? 如果我这样做,增大我代码的并行区域是否会带来更好的可扩展性?”

仔细考虑数据的放置以及必须通过消息共享什么数据。数据被置于最常使用的地方,然后根据需要发送至其他系统。对于以网格表示的应用程序,或具有特定分区的物理域,MPI 软件中常见的做法是围绕子网格或子域添加一行“虚拟”单元。虚拟单元用于存储 MPI 进程发送的数据的值,该进程会更新这些单元。通常虚拟单元不会用在线程化软件中,但是正如您沿着用于消息通过的分区最大程度减少边缘的长度一样,需要使用共享内存为线程最大程度减少分区的边缘。这样可最大程度减少对于线程锁(或关键部分)或关系到缓存所有权的缓存使用代价的需求。

大型多路系统共享全局内存地址空间,但通常具有非均匀的内存访问 (NUMA) 时间。和位于最靠近运行代码的插槽的内存条中的数据相比,最靠近另一插槽的内存条中的数据进行检索所需的时间更长,或者延迟更久。对于靠近的内存的访问延迟更短。

. Latency memory access, showing relative time to access data

图 1. 延迟内存访问,显示访问数据的相对时间。

如果一个线程分配并初始化数据,则通常会将该数据置于最靠近线程分配和初始化正在其上运行的插槽的内存条(图 1)。您可通过每个线程分配以及先引用其将主要使用的内存来改善性能。这通常足以确保内存最靠近线程在其上运行的插槽。一旦创建了线程,并且线程处于活动状态,操作系统通常会将线程留在相同插槽上。有时明确将线程绑定至核心以防止线程迁移较为有利。当数据具有特定模式时,实用的做法是将线程的亲缘性分配、绑定或设置到特定核心以匹配该模式。英特尔 OpenMP 运行时库(英特尔® Parallel Studio XE 2016 的一部分)提供了明确的映射属性,这些属性经过证明可用于英特尔® 至强融核™ 协处理器。

这些类型包括紧凑、分散和平衡。 

  • 紧凑属性将连续或相邻的线程分配至单核上的系统性多线程 (SMT),以将线程分配至其他核心。这在线程和连续编号(相邻)的线程共享数据的地方非常重要。
  • 分散亲缘性功能将线程分配至每个核心,然后再回到初始核心以在 SMT 上安排更多线程。
  • 平衡亲缘性功能以平衡的方式将连续或相邻 ID 的线程分配至相同的核心。如果期望根据英特尔 16.0 C++ 编译器文档优化线程亲缘性,在开始亲缘性时建议进行平衡。平衡的亲缘性设置仅可用于英特尔® 至强融核™ 产品系列。它并非一般 CPU 的有效选项。当利用了至强融核平台上的所有 SMT 时,平衡以及紧凑属性的效果相同。如果在至强融核平台上只利用了某些 SMT,紧凑方法将填补第一批内核上的所有 SMT 并在最后适当保留某些内核。

花些一些时间将线程数据靠近使用它的地方放置很重要。就和数据布局对于 MPI 程序很重要一样,这可能也对线程化软件很重要。  

在内存和数据布局方面,需要考虑两个较短的项目。这些是相对易于解决的部分,但是可能有很大影响。第一个是错误共享,第二个是数据对齐。和线程化软件相关的性能问题之一是错误共享。所运算的每个线程数据均为独立状态。它们之间没有共享,但是会共享包含两个数据点的高速缓存行。正因如此,将其称为错误共享或错误数据共享;虽然它们没有共享数据,但是性能行为表现和已经共享一样。

我们假设每个线程递增自身计数器,但是计数器处于一维阵列中。每个线程递增其自身的计数器。要递增其计数器,内核必须拥有高速缓存行。例如,插槽 0 上的线程 A 获得高速缓存行的所有权并递增 iCount[A] 。同时插槽 1 上的线程 A+1 递增 iCount[A+1],要实现这些操作,插槽 1 上的内核获得高速缓存行的所有权,并且线程 A+1 更新其值。由于高速缓存行中的值改变,使得插槽 0 上处理器的高速缓存行无效。在下次迭代时,插槽 0 中的处理器获得来自插槽 0 的高速缓存行的所有权,并修改 iCount[A] 中的值,该值继而让插槽 1 中的高速缓存行无效。当插槽 1 上的线程准备好写入时,循环重复。受到高速缓存行无效、重获控制以及同步至有性能影响的内存的影响,需要使用大量循环来保持缓存一致性。

对此的最佳解决方案并非让缓存无效。例如,在循环的入口,每个线程可读取其计数并将其存储在其堆栈上的本地变量中(读取不会让缓存无效)。当工作完成时,线程可将该本地值复制回最初的位置(参见图 2)。另一个备选方案是填补数据,使数据主要由其自身高速缓存行中的特定线程使用。

int iCount[nThreads] ;
      .
      .
      .
      for (some interval){
       //some work . . .
       iCount[myThreadId]++ // may result in false sharing
     }

Not invalidating the cache

int iCount[nThreads*16] ;// memory padding to avoid false sharing
      .
      .
      .
      for (some interval){
       //some work . . .
       iCount[myThreadId*16]++ //no false sharing, unused memory
     }

No false sharing, unused memory

int iCount[nThreads] ; // make temporary local copy

      .
      .
      .
      // every thread creates its own local variable local_count
      int local_Count = iCount[myThreadID] ;
      for (some interval){
       //some work . . .
       local_Count++ ; //no false sharing
     }
     iCount[myThreadId] = local_Count ; //preserve values
     // potential false sharing at the end,
     // but outside of inner work loop much improved
     // better just preserve local_Count for each thread

图 2.

相同的错误共享也可能在分配给相邻内存位置的标量上发生。这是如下面代码段所示的最后一种情况:

int data1, data2 ; // data1 and data2 may be placed in memory
                   //such that false sharing could occur
declspec(align(64)) int data3;  // data3 and data4 will be
declspec(align(64)) int data4;  // on separate cache lines,
                                // no false sharing

如果开发人员从一开始就设计并行处理,并最小化共享数据使用,通常要避免错误共享。 如果您的线程化软件没有很好地扩展,尽管有大量独立的工作在持续进行并且有少量障碍(互斥器、关键部分),检查错误共享也非常重要。

 

数据对齐

当以 SIMD 方式(AVX512、AVX、SSE4 等)运算的数据在高速缓存行边界上对齐时 , 软件性能最佳。数据访问未对齐的代价根据处理器系列而有所不同。英特尔® 至强融核™ 协处理器对于数据对齐尤其敏感。  在英特尔至强融核平台上,数据对齐至关重要。该差异在其他英特尔® 至强® 平台上不是很明显,但是当数据和高速缓存行边界对齐时,性能也能够得到显著的改进。因此建议软件开发人员务必在 64 字节边界上对齐数据。在 Linux* 和 Mac OS X* 上,这可通过英特尔编译器选项完成,没有源代码更改,只需使用以下命令行选项: /align:rec64byte。    

对于 C 语言中的动态分配的内存,malloc()可由 _mm_alloc(datasize,64)取代。当使用了 _mm_alloc()时,应当使用 _mm_free()取代 free()。专门针对数据对齐的完整文章位于以下网址:https://software.intel.com/zh-cn/articles/data-alignment-to-assist-vectorization。 

另外也请查看编译器文档。为了展现数据对齐的影响,我们创建了两个相同大小的矩阵,并且两个矩阵都运行该系列第 1 部分中使用的阻塞矩阵多层代码。 对于第一种情况,对齐了矩阵 A,对于第二种情况,特意将矩阵 A 偏移 24 个字节(3 的倍数),在将英特尔 16.0 编译器用于大小从 1200x1200 到 4000x4000 的矩阵的情况下,性能减少 56% 至 63%。在该系列的第 1 部分,我显示了一个表格,其中有使用了不同编译器的循环排序的性能,当一个矩阵偏移时使用英特尔编译器不再有任何性能优势。建议开发人员就数据对齐和可用的选项查看其编译器文档,从而当数据对齐时,编译器能够最有效地利用该信息。用于为偏离高速缓存行的的矩阵评估性能的代码嵌入在第 1 部分的代码中 - 该试验的代码位于:https://github.com/drmackay/samplematrixcode

编译器文档也有详细信息。

为了展现数据对齐的效果,我们创建了两个相同大小的矩阵,并且两个矩阵都运行第 1 部分中使用的阻塞矩阵多层代码。我们对齐了第一矩阵 A,第二个矩阵特意被偏移 24 个字节(3 的倍数),在将英特尔 16.0 编译器用于大小从 1200x1200 到 4000x4000 的矩阵的情况下,性能减少 56% 至 63%。

 

结构阵列对比阵列结构

处理器在内存连续流入时性能更佳。这在高速缓存行的每个元素移入 SIMD 寄存器时很有效, 前提是连续高速缓存行也以有序的方式载入了处理器预取。在结构阵列中,数据可采用类似以下形式的布局:

struct {
   uint r, g, b, w ; // a possible 2D color rgb pixel layout
} MyAoS[N] ;

在该布局中,连续陈列出 rgb 值。如果软件跨彩色平面处理数据,则整个结构可能被拉入缓存,但是每次仅使用一个值,例如 g。如果数据存储在阵列结构中,布局可能类似以下形式:

struct {
   uint r[N] ;
   uint g[N] ;
   uint b[N] ;
   uint w[N] ;
} MySoA ;

如果数据以阵列结构组织,并且软件运算所有 g 值(也可以是 r 或 b),当将高速缓存行引入高速缓存时,可能会在运算中使用整个高速缓存行。数据被更有效地载入 SIMD 寄存器,效率和性能显著改善。在许多情况下,软件开发人员用时间在实际操作中临时将数据移入要运算的阵列结构,然后根据需要将其复制回原位。在可行时,最好避免该额外的复制操作,因为这会占用执行时间。

英特尔 (Vectorization) Advisor 2016“内存访问模式”(MAP) 分析确定了具有连续(“单位步幅”)、非连续并且“非常规”的访问模式的循环:

“步幅分配”列提供关于每个模式在给定源循环中发生的频率的汇总统计数据。在上图中,条形图左边的三分之二为蓝色,表示连续访问模式,而右边的三分之一为红色,其表示非连续内存访问。对于具有纯 AoS 模式的代码,Advisor 也可自动获取特定“建议”以执行 AoS -> SoA 转换。 

在 Advisor MAP 中访问模式以及更为一般的内存位置分析得到简化,具体方法为额外提供内存“占用空间”指标,并将每个“步幅”(即访问模式)诊断映射至特定 C++ 或 Fortran* 对象/阵列名称。有关英特尔 Advisor 的详细信息,请访问

https://software.intel.com/zh-cn/get-started-with-advisor https://software.intel.com/zh-cn/intel-advisor-xe

阵列结构和结构阵列数据布局关系到许多图形程序以及 nbody(例如分子动态),或任何时间数据/属性(例如质量、位置、速度、电量),并且可与点或特定主体关联。通常,阵列结构更加有效,并且具有更高性能。

从英特尔编译器 2016 Update 1 开始,通过引入英特尔® SIMD 数据布局模板(英特尔® SDLT),AoS -> SoA 转换变得更简单。借助 SDLT,可以采用下面的方式方便地重新定义 AoS 容器:

SDLT_PRIMITIVE(Point3s, x, y, z)
sdlt::soa1d_container<Point3s> inputDataSet(count);  

从而可通过 SoA 方式访问 Point3s 实例。在此处阅读有关 SDLT 的更多信息。

几篇专论文章专门阐述了 AoS 对比 SoA 的主题。读者可通过下面的链接查看:

https://software.intel.com/zh-cn/articles/a-case-study-comparing-aos-arrays-of-structures-and-soa-structures-of-arrays-data-layouts

https://software.intel.com/zh-cn/articles/how-to-manipulate-data-structure-to-optimize-memory-use-on-32-bit-intel-architecture
http://stackoverflow.com/questions/17924705/structure-of-arrays-vs-array-of-structures-in-cuda

尽管大多数情况下,阵列结构匹配该模式并提供最佳性能,但也存在少数情况,其中数据参考和使用更紧密地匹配结构阵列布局,并且在该情况下结构阵列可提供更好的性能。

 

总结

下面总结了数据布局和性能方面要遵守的基本原则。将代码结构化以最小化数据移动。在数据处于寄存器或高速缓存中时重新使用它;这也有助于最小化数据移动。循环分块有助于最小化数据移动。对于具有 2D 或 3D 布局的软件尤其如此。考虑并行化布局,包括如何为并行计算分配任务和数据。良好的域分解做法有利于消息传送 (MPI) 和共享内存编程。阵列结构移动的数据通常比结构阵列更少,并且效果更佳。避免错误共享,并创建真正的本地变量或提供填充,从而使每个线程在不同的高速缓存行中引用值。最后,将数据对齐设置为在高速缓存行上开始。 

完整的代码可在以下网址下载: https://github.com/drmackay/samplematrixcode

如果您错过了第 1 部分,可在此处找到它。

您可应用这些技巧并了解代码性能如何得到改善。

基于 Windows® 10 的英特尔® 内存保护扩展:教程

$
0
0

简介

自第六代智能英特尔® 酷睿™ 处理器起,英特尔公司开始推出英特尔® 内存保护扩展(英特尔® MPX),它是一种针对指令集架构的全新扩展,旨在通过帮助抵御缓冲区溢出攻击,增强软件安全性。 在本文中,我们将介绍缓冲区溢出,并提供详细的步骤指导应用开发人员如何保护 Windows® 10 上的应用免受缓冲区溢出攻击。 英特尔 MPX 适用于传统桌面应用和 Universal Windows Platform* 应用。

前提条件

为运行本文提供的示例,您需要准备以下软硬件:

  • 采用第六代智能英特尔® 酷睿™ 处理器和 Microsoft Windows 10 操作系统(2015 年 11 月更新版或更高版本,首选 Windows 10 版本 1607)的计算机(台式机、笔记本电脑或其他外形的计算机)
  • 在 UEFI 中启用英特尔 MPX(如果该选项可用)
  • 安装正确的英特尔 MPX 驱动程序
  • Microsoft Visual Studio* 2015(更新 1 或更高版本 IDE;首选 Visual Studio 2015 更新 3)

缓冲区溢出

从本质上来说,C/C++ 代码更容易遭受缓冲区溢出。 例如,在以下代码中,main() 中的字符串操作函数 “strcpy” 将会导致程序遭受缓冲区溢出攻击的风险。

#include "stdafx.h"
#include <iostream>
#include <time.h>
#include <stdlib.h>

using namespace std;

void GenRandomUname(char* uname_string, const int uname_len)
{
	srand(time(NULL));
	for (int i = 0; i < uname_len; i++)
	{
		uname_string[i] = (rand() % ('9' - '0' + 1)) + '0';
	}
	uname_string[uname_len] = '\0';
}

int main(int argnum, char** args)
{
	char user_name[16];
	GenRandomUname(user_name, 15);
	cout << "random gentd user name: "<< user_name << endl;

	char config[10] = { '\0' };
	strcpy(config, args[1]);

	cout << "config mem addr: "<< &config << endl;
	cout << "user_name mem addr: "<< &user_name << endl;

	if (0 == strcmp("ROOT", user_name))
	{
		cout << "Buffer Overflow Attacked!"<< endl;
		cout << "Uname changed to: "<< user_name << endl;
	}
	else
	{
		cout << "Uname OK: "<< user_name << endl;
	}
	return 0;
}

为了提高准确性,如果我们以 C++ 控制台应用的形式编译并运行以上示例,将 CUSM_CFG 当作参数,那么该程序将正常运行,并且控制台将显示以下输出:

Figure 1 Buffer Overflow

但如果我们重新运行该程序,将 CUSTOM_CONFIGUREROOT 当作参数,输出将“出乎意料”,且控制台显示以下消息:

Figure 2 Buffer Overflow

这一简单示例说明了缓冲区溢出攻击的工作原理。 出现意外输出的原因是 strcpy 函数调用无法检查目标数组的联系。 尽管编译器通常为数组提供额外字节以达到内存对齐,但如果源数组足够长,还是会发生缓冲区溢出。 在这种情况下,程序的一部分运行时内存布局将如下所示(结果可能因编译器或编译选项的不同而有所差异):

Figure 3

英特尔内存保护扩展

借助英特尔 MPX,只需为 Visual Studio C++ 编译器添加编译选项 /d2MPX,就可避免缓冲区溢出这一安全问题。

Figure 4

借助英特尔 MPX 选项进行编译后,该程序将能够抵御缓冲区溢出攻击。 如果我们尝试运行重新进行编译,且包含 CUSTOM_CONFIGUREROOT 参数的程序,运行时例外情况将会增加,并导致程序退出。

Figure 5

我们来深入了解一下生成的汇编代码,看看英特尔 MPX 对该程序有何作用。 从结果来看,原始指令中插入了许多有关英特尔 MPX 的指令,以检测运行时的缓冲区溢出。

Figure 6

现在我们来详细了解一下有关英特尔 MXP 的指令:

bndmk: 在界限寄存器 (%bnd0) 中创建 LowerBound (LB) 和 UpperBound (UB),如上述代码快照所示。
bndmov: 从内存中获取(上下)界限信息并将其放在界限寄存器中。
bndcl: 根据上述代码快照中的参数 (%rax) 检查下界限。
bndcu: 根据上述代码快照中的参数 (%rax) 检查上界限。

故障排除

如果 MPX 无法正常工作。

  1. 复查 CPU、操作系统和 Visual Studio 2015 的版本。 将 PC 启动至 UEFI 设置,检查是否有英特尔 MPX 开关,如有必要打开开关。
  2. 确认英特尔 MPX 驱动程序安装正确并在 Windows* Device Manager 中正常运行。 Figure 7
  3. 检查编译的可执行文件是否包含有关英特尔 MPX 的指令。 插入一个断点,然后运行程序。 如果命中断点,右击鼠标,然后单击Go To Disassembly。 将显示一个新的窗口以供查看汇编代码。

Figure 8

结论

英特尔 MPX 是一款全新的硬件解决方案,有助于抵御缓冲区溢出攻击。 从应用开发人员的角度来看,相比于 AddressSanitizer (https://code.google.com/p/address-sanitizer/) 等软件解决方案,英特尔 MPX 拥有多项优势,包括:

  • 检测指针点在对象之外,但仍然指向有效内存。
  • 英特尔 MPX 更加灵活,可用于许多模块,但不影响其他模块。
  • 与传统代码的兼容性更高,适用于用英特尔 MPX 控制的代码。
  • 由于特殊的指令编码,因此仍然可以发布单一版本的二进制。 在不支持的硬件或操作系统上,与英特尔 MPX 相关的指令将以 NOP(无操作)的形式执行。

在第六代智能英特尔® 酷睿™ 处理器和 Windows 10 上,只需添加编译器选项,即可受益于面向应用的英特尔 MPX,从而帮助增强应用安全性,且丝毫不影响应用的后向兼容性。

相关文章

英特尔® 内存保护扩展启用指南:

https://software.intel.com/zh-cn/articles/intel-memory-protection-extensions-enabling-guide

参考资料

[1] AddressSanitizer: https://code.google.com/p/address-sanitizer/

关于作者

Fanjiang Pei 是软件和解决方案事业部 (SSG) 开发人员关系部门客户端计算移动支持团队的一名应用工程师, 负责为英特尔 MPX、英特尔® Software Guard 扩展等英特尔安全技术提供支持。


基于英特尔® 至强 E5 系列处理器的单节点 Caffe 评分和训练

$
0
0

     在互联网搜索引擎和医疗成像等诸多领域,深度神经网络 (DNN) 应用的重要性正在不断提升。 Pradeep Dubey 在其博文中概述了英特尔®架构机器学习愿景。 英特尔正在实现 Pradeep Dubey 博文中勾勒的机器学习愿景,并正在着手开发软件解决方案以加速执行机器学习工作负载。这些解决方案将包含在未来版本的英特尔®数学核心函数库(英特尔® MKL)英特尔®数据分析加速库(英特尔® DAAL)中。 本技术预览版展示了配备我们正在开发的软件后,英特尔平台将有望实现的性能。  这一版本仅可在支持英特尔® 高级矢量扩展指令集 2(英特尔® AVX2)的处理器上运行。 在未来的文章中,我们将介绍分布式多节点配置可带来的优势。

     本文介绍的预览包功能有限,且并非设计用于生产用途。 此处讨论的特性现已在英特尔 MKL 2017 测试版英特尔 Caffe 分支 (fork)中推出。

    Caffe是伯克利愿景和学习中心 (Berkeley Vision and Learning Center, BVLC) 开发的一个深度学习框架,也是最常用的用于图像识别的社区框架之一。 Caffe 通常作为性能指标评测与 AlexNet(一种图像识别神经网络拓扑)和 ImageNet(一种标签图像数据库)一起使用。

Caffe 可充分利用英特尔 MKL 中优化的数学例程,同时也将可以通过应用代码现代化技术,进一步提升基于英特尔®至强® 处理器的系统的性能。 通过合理使用英特尔 MKL、矢量化和并行化技术,相比未优化的 Caffe 方案,经过优化的方案有望将训练性能提升 11 倍,将分类性能提升 10 倍。

借助这些优化,在整个 ILSVRC-2012 数据集上训练 AlexNet* 网络以在 80% 的时间实现排名前五的准确度,所需的时间从 58 天缩短至大约 5 天。

开始

我们正努力为软件产品开发新功能,目前您可使用本文附带的技术预览包再现展示的性能结果,甚至使用您自己的数据集训练 AlexNet。

该预览包支持 AlexNet 拓扑,并引入了“intel_alexnet”模型,它类似于 bvlc_alexnet,添加了 2 个全新的“IntelPack“和“IntelUnpack”层,以及优化的卷积、池化和规范化层。 此外,我们还更改了验证参数以提高矢量化性能,将验证 minibatch 的数值从 50 提高到 256,将测试迭代次数从 1000 减少到 200,从而使验证运行中使用的图像数量保持不变。 该预览包在以下文件中加入了 intel_alexnet 模型:

  • models/intel_alexnet/deploy.prototxt
  • models/intel_alexnet/solver.prototxt
  • models/intel_alexnet/train_val.prototxt.

“intel_alexnet”模型支持您训练和测试 ILSVRC-2012 训练集。

开始使用该预览包时,请确保“系统要求和限制”中列出的所有常规 Caffe 依赖项均已安装在系统中,然后:

  • 对预览包进行解包。
  • 为以下“intel_alexnet”模型文件中的数据库、快照位置和图像均值文件指定路径。
    • models/intel_alexnet/deploy.prototxt
    • models/intel_alexnet/solver.prototxt
    • models/intel_alexnet/train_val.prototxt
  • 为“系统要求和限制”部分列出的软件工具设置运行时环境。
  • 在 LD_LIBRARY_PATH 环境变量中添加 ./build/lib/libcaffe.so 路径
  • 设置线程环境:
    $> export OMP_NUM_THREADS=<N_processors * N_cores>
    $> export KMP_AFFINITY=compact,granularity=fine
  • 使用以下命令在单节点上执行计时:
    $> ./build/tools/caffe time \
           -iterations <number of iterations> \
           --model=models/intel_alexnet/train_val.prototxt
  • 使用以下命令在单节点上执行训练:
    $> ./build/tools/caffe train \
           --solver=models/intel_alexnet/solver.prototxt

系统要求和限制

该预览包与未优化的 Caffe 拥有相同的软件依赖项:

英特尔 MKL 11.3 或更高版本

硬件兼容性:

此软件仅使用 AlexNet 拓扑进行了验证,可能不适用于其他配置。

支持:

如有关于该预览包的任何问题和建议,请联系:mailto:intel.mkl@intel.com

预测和测量并行性能

$
0
0

预测和测量并行性能 (PDF 310KB)

摘要

构建软件的并行版本可使应用在更短的时间内运行指定的数据集,在固定时间内运行多个数据集,或运行非线程软件禁止运行的大型数据集。 并行化的成功通常通过测量并行版本的加速(相对于串行版本)来进行量化。 除了上述比较之外,将并行版本加速与可能加速的上限进行比较也十分有用。 通过阿姆达尔定律和古斯塔夫森定律可以解决这一问题。

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

背景

应用运行的速度越快,用户等待结果所需的时间越短。 此外,执行时间的缩短使用户在可接受的时间内能够运行更大规模的数据集(例如,更多的数据记录,更多的像素,或更大的物理模型)。 串行与并行执行时间之间一个具体的比较指标便是加速比(speedup)。

简单来说,加速比是串行执行时间与并行执行时间的比率。 例如,如果串行应用运行需 6720 秒,对应的并行应用运行需 126.7 秒(使用 64 个线程和内核),则并行应用的加速比是 53X (6720/126.7 = 53.038)。

对于扩展良好的应用,加速比增加的速度应与内核(线程)数量增加的速度相同或接近。 当增加使用的线程数时,如果测量的加速比不能维持不变或开始下降,那么就测量的数据集,该应用的扩展性不够理想。 如果该数据集是典型的实际数据集,而应用将在此之上执行,那么该应用的扩展性能则不理想。

与加速比相关的另一个指标是效率(efficiency)。 正如加速比是衡量并行执行比串行执行快多少的指标,效率表示的是软件对系统计算资源的利用程度。 要计算并行执行的效率,只需将观察到的加速比除以使用的内核数, 然后将得到的数值以百分数表示即可。 例如,加速比为 53X, 使用 64 个内核,那么效率就等于 82% (53/64 = 0.828)。 这意味着,在应用执行过程中,平均每个内核大约有17% 的时间处于闲置状态。

古斯塔夫森定律

在启动一个并行化项目前,开发人员会希望预估他们能够实现的性能提升量(加速比)。 如果知道(或预估出)能够以并行方式执行的串行代码的百分数,那么开发人员可使用阿姆达尔定律计算应用的加速比上限,无需实际编写任何并发代码。 本系列介绍了阿姆达尔定律公式的几种变形。 每种变形均使用并行执行时间 (pctPar) 、串行执行时间 (1 - pctPar) 和线程/内核 (p) 的百分数(建议)。 下面是一个简单的阿姆达尔定律公式,用于评估基于 p 个内核上并行应用的加速比。
 

该公式只是串行时间(标准化为 1)与预估的并行执行时间的简单相除,使用标准化的串行时间的百分数。 并行执行时间表示为串行执行的百分数 (1 - pctPar)加上能够以并行方式执行的百分数与所用内核数 (pctPar/p) 的除数。 例如,如果 95% 的串行应用运行时间可以在 8 个内核上以并行方式执行,根据阿姆达尔定律,预估的加速比等于 6X (1 / (0.05 + 0.95/8)= 5.925)。

除了在公式中的小于或等于关系 (=),阿姆达尔定律公式假设这些能够以并行方式执行的计算可被无限内核数整除。 这一假设实际消除了分母中的第二项,意味着最大的加速比即是剩余串行执行百分数的倒数。

因为忽略了实际开销,例如通信、同步和其它线程管理,以及无限内核处理器的假设,阿姆达尔定律一直饱受批评。 除了没有考虑并发算法固有的开销,对阿姆达尔定律最强烈的批评之一是,随着内核数量的增加,处理的数据量也可能会增加。 阿姆达尔定律假设不论内核数量如何,数据集大小均为固定,并且整体串行执行时间保持不变。

古斯塔夫森定律

如果使用 8 核的并行应用能够计算的数据集是原始大小的 8 倍,串行部分的执行时间会增加吗? 即使有增加,它也并非与数据集的增加同比例增长。 实际数据显示串行执行时间几乎保持不变。

斯塔夫森定律又被称为扩展的加速比(scaled speedup),它考虑了数据大小与内核数量成比例的增加并计算应用的加速比(上限),假设大数据集能够以并行方式执行。 扩展的加速比公式如下:
 

与阿姆达尔定律公式相同,p代表内核数量。 为简化表述,对于指定的数据集大小, s代表并行应用中的串行执行时间的百分数。 例如,如果在 32 个内核上 1% 的执行时间用于串行执行,对于同一数据集,基于单个内核和单个线程运行的应用的加速比是:
 

现在来考虑阿姆达尔定律基于这些假设估计的加速比。 假设串行执行的百分比是 1%,阿姆达尔定律等式得出 1/(0.01 + (0.99/32)) = 24.43X。 这是个错误计算,因为给定的串行时间百分数与 32 内核执行有关。 该示例没有指出对于更多或更少的内核(甚至只有一个内核),对应的串行执行百分数将是多少。 如果代码扩展完美,并且数据大小与内核数同时扩展,那么该百分数能够保持不变,阿姆达尔定律计算的结果将是 32 内核上(固定大小)单核问题的预测加速比。

另一方面,如果在 32 内核的案例中知道总的并行应用执行时间,则可以计算全部串行执行时间,并且针对固定大小问题的加速比(进一步假设该值可以使用单核计算)可以通过阿姆达尔定律基于 32 内核进行预测。 假设在 32 内核上并行应用的总执行时间是 1040 秒,则该时间的 1% 是串行执行时间,或 10.4 秒。 乘以 32 内核上并行执行的秒数 (1029.6),该应用完成总工作量所花时间为 1029.6*32+10.4 = 32957.6 秒。 非并行时间(10.4 秒)是总工作时间的 0.032%。 使用该数字,阿姆达尔定律计算出的加速比为 1/(0.00032 + (0.99968/32)) = 31.686X。

运用斯塔夫森定律时,必须知道并行执行期间串行时间的百分数,因此该公式的一个典型用例是计算扩展的并行执行(数据集大小随着内核数量的增加而增加)与相同大小问题串行执行的加速比。 从上面的示例可以看出,由于在阿姆达尔定律的公式中有关应用执行数据的严格使用,得出的估值比扩展的加速比公式得出的值悲观得多。
 

建议

在计算加速比时,必须对最佳的串行算法和最快的串行代码进行比较。 通常,非最佳串行算法将更容易并行化。 即便如此,虽然有更快的串行版本,但也不是所有人都会使用串行代码。 因此,即使底层算法不同,必须使用最快串行代码中的最佳串行运行时间来计算可比较并行应用的加速比。

在说明加速比时,应使用乘数值。 过去,加速比一直以百分数表示。 在本文中,使用百分数会引起困惑。 例如,如果说并行代码比串行代码快 200%,那么它的运行时间是串行版本时间的一半,还是该时间的三分之一? 105% 的加速比是几乎与串行执行时间相同还是比串行执行时间快两倍? 基准串行时间是 0% 加速比还是 100% 加速比? 另一方面,如果并行应用的加速比是 2X,很显然它使用一半的时间(即,并行版本在相同的时间内能够执行两次,而串行代码执行一次)

在极少数情况下,应用的加速比大于内核数。 这种现象被称为超级线性加速。 发生超级线性加速的典型原因是固定大小数据集被分解得足够小(对内核而言),可以放入本地高速缓存。 当以串行方式运行时,数据必须通过高速缓存获取,在获取期间处理器只能等待。 如果数据足够大,需占用清空之前使用的某些高速缓存行,那么后续对这些高速缓存行的任何复用都会导致处理器再次等待。 当数据被分解成可放入内核上高速缓存的数据块时,一旦这些数据被全部存入高速缓存,则无需经历复用高速缓存行所带来的等待复用。 因此,使用多个内核可以消除在单个内核上与串行代码执行相关的一些系统开销。 这样,过小的数据集(小于一般的数据大小)便会产生性能提升的错觉。
 

使用指南

此外还有其它并行执行模型尝试对阿姆达尔定律简单模型中的缺陷给出合理假设。

然而,因为其简单性和用户理解这只是理论上限(几乎不可能达到或超越),所以阿姆达尔定律仍是表示串行应用加速比潜力的一项简单、有用的指标。
 

更多资源

现代代码社区

John L. Gustafson。 "重新评估阿姆达尔定律,"《美国计算机学会通讯》第 31 卷,第 532-533 页,1988 年。

Michael J. Quinn。 《利用 MPI 和 OpenMP 的 C 并行编程》. McGraw-Hill,2004 年。

粒度与并行性能

$
0
0

粒度与并行性能 (PDF 270KB)

摘要

实现出色并行性能的关键是选择适合应用的粒度。 粒度是指并行任务的实际工作量。 如果粒度太细,则并行性能会因通信开销增加而受到影响。 如果粒度太粗,则并行性能会因负载不均衡而受到影响。 为确保实现最佳并行性能,开发人员应确定适合并行任务的粒度(通常粒度越大越好),同时还应避免负载不均衡和通信开销增加的情况发生。

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

背景

多线程应用的并行任务工作量大小(粒度)会对其并行性能产生很大影响。 在分解一项应用使之适用于多线程处理时,开发人员通常采用的方法是从逻辑上将问题分割成尽量多的并行任务,或者 在并行任务内根据共享数据与执行顺序决定进行哪些必要的通信。 由于分割任务、将任务分配给线程以及在任务之间进行数据通信(共享)涉及到一定的成本,开发人员通常需要聚合或整合分割的任务,用于避免随之产生的开销,尽量实现应用高效运行。 通过聚合分割的任务可确定并行任务的最佳粒度。

粒度通常与工作负载在线程之间的均衡程度有关。 尽管平衡大量小型任务的工作负载更容易,但这样做却可能导致通信和同步等方面的并行开销过高。此时,开发人员可以通过将小型任务整合成一项任务,增加每项任务的粒度(工作量)来减少并行开销。 您可以借助英特尔® Parallel Amplifier 等工具确定应用的适宜粒度。

本文将列举下列代码示例向您展示如何通过减少通信开销和确定线程的适宜粒度来提高并行程序的性能。 本文所列出的所有代码示例均为质数计数算法,该算法采用一套简单的强力测试方法来执行,让每个潜在质数除以所有可能存在的因数直到除数被找到或该数字经证实为质数为止。 由于正奇数可以被(4k+1)或(4k+3)(其中k = 0)整除,因此使用下列代码还可以计算各种形式的质数数量。 所列举的代码示例将计算从 300 到 100 万的所有质数数量。

下列为采用 OpenMP* 的并行版代码:
 

#pragma omp parallel
{ int j, limit, prime;
#pragma omp for schedule(dynamic, 1)
  for(i = 3; i <= 1000000; i += 2) {
    limit = (int) sqrt((float)i) + 1;
    prime = 1; // assume number is prime
    j = 3;
    while (prime && (j <= limit)) {
      if (i%j == 0) prime = 0;
      j += 2;
    }

    if (prime) {
      #pragma omp critical
      {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
      }
    }
  }
}

运行该代码的通信开销(表现为同步开销)较高,且个别任务的规模太小,不足以分配给多个线程。 在循环内部存在一个可用于为增加计数变量提供安全机制的关键区域。 这一关键区域可将同步与锁定开销添加至并行循环(如图 1 中英特尔® Parallel Amplifier 视图所示)。
 


图 1.锁定与等待分析结果显示,OpenMP* 关键区域是同步开销产生的原因。

在一个大型数据集内,根据数值增加计数变量是削减开销的常用方法。 通过清除关键区域和添加 OpenMP reduction 子句可避免生成锁定与同步开销:
 

#pragma omp parallel
{
  int j, limit, prime;
  #pragma omp for schedule(dynamic, 1)
    reduction(+:numPrimes,numP41,numP43)
  for(i = 3; i &;lt;= 1000000; i += 2) {
    limit = (int) sqrt((float)i) + 1;
    prime = 1;  // assume number is prime
    j = 3;
    while (prime && (j &;lt;= limit))
    {
      if (i%j == 0) prime = 0;
      j += 2;
    }

    if (prime)
    {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
    }
  }
}

根据循环所执行的迭代次数在循环体内清除关键区域可以使迭代执行速度提升几个数量级。 然而,运行上述代码可能仍然会产生一些并行开销。 这些开销主要由工作量过小的任务导致。 Schedule(dynamic,1)子句规定调度程序一次可向每个线程动态分配一次迭代(数据块)。 每个辅助线程会处理一次迭代,接着返回调度程序,并同步获取另外一次迭代。 通过增加数据块大小,我们可以增加分配给线程的每项任务的工作量,进而缩减每个线程与调度程序实现同步所需的时间。

尽管采用上述方法可以提高并行性能,但请务必记住:过度增加粒度会导致负载不均衡(上文已提及)。 例如,在下列代码中将数据块大小增加到 10000:
 

#pragma omp parallel
{
  int j, limit, prime;
  #pragma omp for schedule(dynamic, 100000)
    reduction(+:numPrimes, numP41, numP43)
  for(i = 3; i <= 1000000; i += 2)
  {
    limit = (int) sqrt((float)i) + 1;
    prime = 1; // assume number is prime
    j = 3;
    while (prime && (j <= limit))
    {
      if (i%j == 0) prime = 0;
      j += 2;
    }

    if (prime)
    {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
    }
  }
}

通过 Parallel Amplifier 对上述代码的执行情况进行的分析显示,使用四个线程完成的计算量分布不均衡(如图 2 所示)。 在该计算示例中,每个数据块的工作量各不相同,可用于指派任务的数据块太少(四个线程瓜分十个数据块),因而才会导致负载不均衡的情况发生。 随着潜在质数的值不断增加(从 for循环开始),需要运行更多迭代来让质数除以尽可能多的因数(在 while循环中)。 这样一来,每个数据块完成全部工作量所需的 while 循环迭代比旧数据块要多。
 


图 2.并发性分析结果显示,每个线程所使用的执行时间存在不均衡性。

在为程序选择合适的粒度时应采用更适宜的工作量大小(100)。 此外,连续任务之间存在的工作量差异在旧数据块上表现得不太明显,通过采用静态调度程序替代动态调度程序可以进一步消除并行开销。 下列代码显示改写 schedule 子句将最终削减该代码片断的开销,最大限度提高整体并行速度。
 

#pragma omp parallel
{
  int j, limit, prime;
  #pragma for schedule(static, 100)
    reduction(+:numPrimes, numP41, numP43)
  for(i = 3; i <= 1000000; i += 2)
  {
    limit = (int) sqrt((float)i) + 1;
    prime = 1;  // assume number is prime
    j = 3;
    while (prime && (j <= limit))
    {
      if (i%j == 0) prime = 0;
      j += 2;
    }

    if (prime)
    {
      numPrimes++;
      if (i%4 == 1) numP41++;  // 4k+1 primes
      if (i%4 == 3) numP43++;  // 4k-1 primes
    }
  }
}

建议

多线程代码的并行性能取决于其粒度:如何在线程之间分配工作任务以及如何在这些线程之间进行通信。 下面就通过调整粒度提高并行性能提供一些指导:
 

  • 了解您的应用
    • 了解需要并行处理的应用的各个部分正在执行的工作任务数量。
    • 了解应用的通信要求。 同步化是一种常见的通信形式,但实施同步化需要考虑在各个内存层面(高速缓存、主内存等)上进行讯息传递和数据共享的开销。
  • 了解您的平台和线程模式
    • 了解采用线程模式在目标平台上实施并行和同步化的成本。
    • 确保应用的每项并行任务工作量比线程负担大。
    • 最大限度减少同步操作和同步化成本。
    • 使用英特尔® 线程构建模块并行算法中的分区对象 (partitioner object),支持任务调度程序为每项工作任务选择合适的粒度以及实现执行线程上的负载平衡。
  • 了解您的工具
    • 在英特尔® Parallel Amplifier“锁定与等待”分析中,锁定、同步和并行开销高是通信开销过高的标志。
    • 在英特尔® Parallel Amplifier“并发性”分析中,负载不均衡是粒度过大或线程间任务分配需要优化的标志。

使用指南

尽管上述代码示例多次提及 OpenMP,但本文所提供的所有建议和指导均适用于 Windows 线程和 POSIX* 线程等其它线程模式。 所有线程模式都会产生与其各类功能(如实施并行化、锁定、关键区域、讯息传递等)相关的开销。本文在此建议在保持负载平衡的情况下减少通信开销和增加每个线程的工作量,这一建议适用于所有线程模式。 但因不同线程模式所产生的不同成本可能导致开发人员选择不同的粒度。
 

更多资源

现代代码社区

Clay Breshears,《并发的艺术》,O'Reilly Media, Inc.,2009 年。

Barbara Chapman、Gabriele Jost 和 Ruud van der Post,《利用 OpenMP 可移植共享内存并行程序设计》, 麻省理工学院出版社,2007 年。

英特尔® 线程构建模块

面向开放源代码的英特尔线程构建模块

James Reinders, 英特尔线程构建模块: 针对多核处理器并行机制配置 C++。 O'Reilly Media, Inc. Sebastopol, CA, 2007。

Ding-Kai Chen 等,"并行系统上同步化与粒度的影响",第 17 届年度计算机架构国际会议记录,1990 年,美国华盛顿西雅图。

负载平衡与并行性能

$
0
0

负载平衡与并行性能 (PDF 199KB)

摘要

实现线程之间应用工作负载平衡是确保出色性能的关键。 实现负载平衡主要是为了最大限度缩短线程的闲置时间。 在尽量减少工作任务分配开销的情况下,在所有线程之间平均分配工作负载,可以减少浪费在不能进行运算的闲置线程上的时间,进而可显著提高性能。 然而,实现完美的负载平衡绝非易事,这主要取决于应用并行性、工作负载、线程数量、负载平衡策略和线程实施情况。

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

背景

在执行计算任务时拥有一个闲置内核无异于拥有一项废弃资源,在该内核上实施有效并行操作会延长线程化应用的整体运行时间。 内核处于闲置状态的原因有很多种,需要从内存或 I/O 中取出便是其中一个原因。 尽管完全避免内核进入闲置状态不太可能,但编程人员仍然可以采取一些措施来缩短闲置时间,如采用重叠 I/O、内存预取的方式或重新排列数据访问模式的顺序,提高高速缓存利用率。

同样,闲置线程在执行多线程任务时也相当于废弃资源。 分配给各线程的工作量不一样会导致名为“负载不均衡”的状况发生。 这种不均衡程度越大,处于闲置状态的线程就会越多,完成计算任务所需的时间便会越长。 分配给可用线程的各部分计算任务越均衡,完成整个计算任务的时间将会越短。

例如,一项任务由十二项独立任务组成,完成这些独立任务所需要的时间分别是: {10, 6, 4, 4, 2, 2, 2, 2, 1, 1, 1, 1}。 假设现有四个线程共同承担这项计算任务,最简单的任务分配法是按照上述时间排列顺序为每个线程分配三项任务, 即线程 0 完成所有分配的任务需要 20 个时间单元(10+6+4),线程 1 需要 8 个时间单元(4+2+2),线程 2 需要 5 个时间单元(2+2+1),线程 3 则只需要 3 个时间单元(1+1+1)。 图 1(a)展示了这一任务分配状态,由此可见,完成全部十二项任务总共需要 20 个时间单元(完成整个任务所需时间应以最后完成的子任务用时为准)。
 

 

图 1.四个线程之间的任务分配示例。


您也可以采用一种更合理的任务分配法,即线程 0 完成一项任务所需时间是 {10},线程 1 完成四项任务所需时间是 {4, 2, 1, 1},线程 2 完成三项任务所需时间是 {6, 1, 1},而线程 3 完成四项任务所需时间是 {4, 2, 2, 2}(如图 1(b)所示)。 这样安排时间的优势是完成整个任务只需 10 个时间单元,四个线程中只有两个线程分别闲置了 2 个时间单元。
 

建议

如果完成所有任务所需时间长度相同,则在可用线程之间实施静态任务分配(即将整个任务划分为相同数量的子任务组并将每个子任务组分配给每个线程)是一种简单且合理的解决方案。 但实际上就算事先已知道所有任务的执行时间长度,要找到一个在线程间实施最佳任务分配的方法仍然十分困难。 如果各项子任务的执行时间长度不同,则可能需要采用一种更加动态的任务分配法来分配线程任务。

在默认情况下,OpenMP* 向线程调度迭代的策略是静态调度(如果不是则会另外注明)。 当迭代之间的工作负载不同以及负载模式不可预知时,采用动态调度迭代的方法可以更好地平衡负载。 动态调度和指数调度这两种静态调度替代方案都会通过 schedule 子句指定。 在动态调度下,迭代数据块分配给线程;一旦分配完成,线程会申请获得一个新的迭代数据块。 Schedule 子句的可选数据块参数会指明用于动态调度的迭代数据块固定尺寸。
 

#pragma omp parallel for schedule(dynamic, 5)
  for (i = 0; i < n; i++)
  {
    unknown_amount_of_work(i);
  }

指数调度最初会向线程分配大型迭代数据块;分配给所需线程的迭代数量会随着未分配迭代集的减少而减少。 由于分配模式不同,指数调度的开销往往少于动态调度。 Schedule 子句的可选数据块参数会指明在指数调度下一个数据块中所分配的迭代最低数量。
 

#pragma omp parallel for schedule(guided, 8)
  for (i = 0; i < n; i++)
  {
    uneven_amount_of_work(i);
  }

其中一个特例是迭代之间的工作负载单调递增或递减。 例如,下三角形矩阵中每行元素数量会以正则表达式的形式增加。 在此类情况下,通过静态调度设置一个相对较低的数据块尺寸(创建大量数据块/任务)可能有助于实现良好的负载平衡,同时还不会产生采用动态调度或指数调度所导致的开销。
 

#pragma omp parallel for schedule(static, 4)
  for (i = 0; i < n; i++)
  {
    process_lower_triangular_row(i);
  }

如果调度策略不明显,采用 运行时调度可以随意改变数据块尺寸和调度类型,而无需对程序进行重新编译。

在使用英特尔® 线程构建模块(英特尔® TBB)的parallel_for算法时,调度程序会将迭代空间划分为可分配给线程的小型任务。 一旦某些迭代的计算用时比其它迭代长,英特尔® TBB 调度程序能够从线程中动态“盗取”任务,以便更好地实现线程间的工作负载平衡。

显式线程模式(如 Windows* 线程、Pthreads* 和 Java* 线程)无法自动为线程调度一系列独立任务。 编程人员必须根据需要将这种能力编入应用程序中。 静态调度任务是一种十分简单、直接的调度方法, 而动态调度任务则可通过两种相关的方法轻松予以实施: 生产者/消费者(Producer/Consumer)模式和老板/工人(Boss/Worker)模式。 在前一个模式下,一个线程(生产者)会将任务置入共享队列结构中,而消费者线程会根据需要清除要处理的任务。 然而在非必要的情况下,如果部分预处理需在消费者线程获取任务之前完成,这时通常采用生产商/消费者模式。

在老板/工人模式下,工人线程与老板线程会在需要直接分配的工作任务增多时会合。 在划分任务十分简单的情况下(如将各类指数分配给数组进行处理),可以采用具备适宜同步化程度的全局计数器来取代单独的老板线程, 即工人线程访问当前数值并针对下一条需要承担更多工作任务的线程调整(可能增加)计数器。

无论采用哪种任务调度模式,您都必须使用适量的线程和正确的线程组合,以确保这些肩负工作任务的线程执行所需计算任务,而不是进入闲置状态。 例如,如果消费者线程有时处于闲置状态,则您需要减少消费者线程数量或可能需要再配备一条生产者线程。 采用何种解决方案主要取决于算法以及需要分配的任务数量与执行时间长度。
 

使用指南

所有动态任务调度方法都将因分配任务而产生一定的开销。 将独立的小型任务整合成为一项可分配的工作任务有助于减少上述开销;相应地,如果采用 OpenMP schedule 子句,您需要在任务内设置代表最少迭代次数的非默认数据块尺寸。 将一项任务划分成多项计算任务的最佳方法取决于需要完成的计算量、线程的数量以及执行计算任务时可以使用的其它资源。
 

更多资源

现代代码社区

Clay Breshears, 《并发的艺术》, O'Reilly Media, Inc., 2009年。

Barbara Chapman、Gabriele Jost 和 Ruud van der Post,《利用 OpenMP 可移植共享内存并行程序设计》,麻省理工学院出版社,2007 年。

英特尔® 线程构建模块

面向开放源代码的英特尔线程构建模块

James Reinders, 英特尔线程构建模块: 针对多核处理器并行机制配置 C++, O'Reilly Media, Inc. Sebastopol, CA, 2007 年。

M. Ben-Ari, 《并行与分布式编程的原则》,(第二版),Addison-Wesley,2006 年。

通过避免或消除人工相关性实现并行性

$
0
0

通过避免或消除人工相关性实现并行性 (PDF 186KB)

摘要

实现线程之间应用工作负载平衡是确保出色性能的关键。 实现负载平衡主要是为了最大限度缩短线程的闲置时间。 在尽量减少工作任务分配开销的情况下,在所有线程之间平均分配工作负载,可以减少浪费在不能进行运算的闲置线程上的时间,进而可显著提高性能。 然而,实现完美的负载平衡绝非易事,这主要取决于应用并行性、工作负载、线程数量、负载平衡策略和线程实施情况。

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

背景

面向并行处理的多线程是确保性能的重要因素,同时也对确保每个线程高效运行起着重要的作用。 尽管优化编译器有助于实现这一点,程序员一般不会通过重复利用数据以及选择有利于设备的指令,来更改源代码以提高性能。 遗憾的是,能够提高串行性能的相同方法也会产生数据相关性,从而难以通过多线程获得更出色的性能。

重复使用中间结果来避免重复计算就是一个例子。 例如,用相邻图像中的加权平均像素(包括该图像)来替换每个图像像素,便可通过模糊的方式来弱化图像。 以下伪代码介绍了 3x3 模糊模板:
 

for each pixel in (imageIn)
  sum = value of pixel
  // compute the average of 9 pixels from imageIn
  for each neighbor of (pixel)
    sum += value of neighbor
  // store the resulting value in imageOut
  pixelOut = sum / 9

事实上,多种计算都会使用所有的像素值,从而有助于通过重复使用数据来提高性能。 在以下伪代码中,中间结果被计算和使用了三次,从而获得了更佳的串行性能。
 

subroutine BlurLine (lineIn, lineOut)
  for each pixel j in (lineIn)
    // compute the average of 3 pixels from line
    // and store the resulting value in lineout
    pixelOut = (pixel j-1 + pixel j + pixel j+1) / 3

declare lineCache[3]
lineCache[0] = 0
BlurLine (line 1 of imageIn, lineCache[1])
for each line i in (imageIn)
  BlurLine (line i+1 of imageIn, lineCache[i mod 3])
  lineSums = lineCache[0] + lineCache[1] + lineCache[2]
  lineOut = lineSums / 3

这种优化方式使得输出图像的相邻行的计算之间产生了相关性。 如果并行计算该循环的迭代,相关性将会造成错误的结果。

另一个常见的例子是循环内部的指针发生偏移:
 

ptr = &someArray[0]
for (i = 0; i < N; i++)
{
  Compute (ptr);
  ptr++;
}

通过增加 ptr,代码可能会利用寄存器增量的快速运算方法,并避免使用计算所有迭代的someArray[i]的算法。 在所有计算相互独立时,指针将会产生依赖性,它在每次迭代中的值将取决于其在之前迭代中的值。

最后,经常会发生这样的情况,当需要执行并行算法时,数据结构已被用于其它目的,从而会无意地阻碍并行性。 稀疏矩阵算法就是这样的例子。 因为大多数矩阵元素都为零,常规矩阵表达式通常会被“封装的 (packed)”形式所代替,包含了元素值以及相关的偏移量,用来跳过零值的项。

本文介绍了在这些棘手情形中有效引入并行性的策略。
 

建议

当然,最好能够找到无需移除现有优化或进行大量源代码变更同时能够充分利用并行性的方式。 在移除串行优化以利用并行性之前,需要考虑是否能够通过将现有内核运用于整体问题的子集来保留优化。 通常情况下,如果初始算法包含并行性,则也可以将子集作为独立单元进行定义,并进行并行计算。

为了有效地实现模糊运算线程化,可以考虑将图像细分为子图像,或固定大小的数据块。 模糊算法支持独立地对数据块进行计算。 以下伪代码阐释了图像模块化的使用方法:
 

// One time operation:
// Decompose the image into non-overlapping blocks.
blockList = Decompose (image, xRes, yRes)

foreach (block in blockList)
{
BlurBlock (block, imageIn, imageOut)
}

用于模糊整个图像的现有代码可在执行 BlurBlockk 时重复使用。 利用 OpenMP 或显式线程来并行运算多个数据块有助于获得多线程优势,并保留最初优化的内核。

在其它情况下,由于现有串行优化的优势不足以冲抵所有迭代的整体成本,所以没有必要进行模块化。 通常在迭代的粗粒度足以使并行处理加速时会出现此类情况。 指针增量就是这样的例子。 归纳变量可以轻松地为显式指数 (explicit indexing) 所取代,从而移除相关性,并支持循环进行简单的并行处理。
 

ptr = &someArray[0]
for (i = 0; i < N; i++)
{
  Compute (ptr[i]);
}

注意,初始优化虽然比较小,但不可丢失。 编译器通常会通过利用增量或其它快速运算对指数计算进行大范围的优化,从而带来串行和并行性能的双重优势。

在其它情况下,比如涉及封装稀疏矩阵的代码,进行线程化会更加困难。 通常情况下,对数据结构进行解包并不可行,但通常可以将矩阵细分为数据块,将指针存储在每个数据块的开始位置。 当这些矩阵与合适的数据块算法实现配对时,便可同时获得封装表达式和并行性的双重优势。

上面介绍的模块化方法更加普遍,被称为“域分解 (domain decomposition)。” 分解后,每个线程都可在一个或多个域中独立运行。 在某些情况下,算法和数据的特性可以表明,每个域中的工作几乎是连续进行的。 在其它情况下,工作的总量会因域的不同而有所差异。 而在域的数量与线程的数量相等的情况下,并行性能将会受到负载不平衡的限制。 总而言之,最好能够确保域的数量要大于线程的数量。 这将有助于通过动态调度 (dynamic scheduling) 等技术来实现整个线程的负载平衡。
 

使用指南

一些串行优化能够带来巨大的性能提升。 要确保并行处理加速的优势超过与移除优化有关的性能损失,考虑一下所需的处理器的数量。

引入数据块算法有时会阻碍编译器区分别名 (aliased) 和非别名 (unaliased) 数据。 如果在模块化后,编译器无法再确定数据是否属于非别名数据,那么性能将会受到影响。 可考虑利用严格的关键词来明确地阻止进行别名化 (aliasing)。 利用程序间的优化也能帮助编译器检测非别名数据。
 

更多资源

现代代码社区

OpenMP* 规范

Viewing all 583 articles
Browse latest View live


<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>