黑板:优化
今天的内容是关于优化的,主要讨论了如何在开发中提高代码的效率,尤其是当游戏的帧率出现问题时。优化并不总是要将代码做到最快,而是要确保代码足够高效,以避免性能问题。优化的过程是一个反复迭代的过程,目标是找到一个“足够好”的解决方案,而不是追求极致优化。
优化的第一步并不是直接优化代码,而是要进行测量和分析。这一步很重要,因为只有了解代码的表现和瓶颈,才能有效地进行优化。测量代码的性能,确定哪些部分的性能需要提升,是成功优化的关键。
在优化过程中,首先要了解内存访问成本、指令周期以及如何合理利用CPU和GPU的性能。优化的目的是通过合理的设计和改进,使得程序的效率达到预期目标,而不一定是将其推到极限。
虽然不涉及非常深入的技术细节,如缓存行别名等高级优化,但会介绍一些通用的优化方法,适用于大多数场景。对于那些对优化有更深入兴趣的人,可以继续学习这些更高级的技巧和理论。
总的来说,优化是一个平衡的过程,需要根据需求做出合理的调整,目标是提高性能并确保开发进度。
黑板:CPU + GPU 指令
在计算机中,CPU和GPU是两个重要的处理单元,它们执行游戏中的每一帧操作,且往往是同时工作的。它们都属于芯片,通常焊接在主板上或作为独立显卡存在。这些芯片的工作原理是解码一系列的指令流,也就是一组按特定格式排列的字节指令。每条指令会让处理器执行某种计算或操作。
这些指令可以分为两大类:一类是加载(load)和存储(store)指令,它们涉及内存的读写操作。加载指令从内存中获取数据并将其放入处理器的寄存器或缓存中进行操作,而存储指令则是将计算结果写回内存。
另一类指令是算术逻辑单元(ALU)指令,这类指令执行各种计算任务,比如加法、减法、乘法等,直接影响游戏中的逻辑处理。通过这些指令,CPU和GPU能够执行复杂的计算和数据操作,从而推动游戏的运行。
黑板:广义数学运算(SIMD)
现代CPU和GPU的处理方式通常依赖于一种称为SIMD(单指令多数据)的技术。这意味着处理器通过一条指令同时对多个数据进行操作,而不是逐个数据执行相同的操作。通常情况下,最基本的SIMD操作是处理四个数据项,实际上很多时候会处理更多数据,比如8个、16个甚至64个数据项。
举个例子,当我们写出类似“float x = y + 3;
”的代码时,实际执行过程中,CPU或GPU并不会单独处理每一个数据项。它们会同时对多个数据项执行加法操作,例如四个 y
值加上 3
,并且可能会丢弃除一个结果外的其他计算结果。这种做法的原因在于,现代处理器的设计就是为了通过一次指令处理多个数据项,从而提高计算效率。
这种设计的目的是因为解码和处理这些指令流并非无代价。处理器在处理这些指令时需要进行大量的工作,尤其是在执行数学运算时,使用SIMD技术可以大幅提高处理器的效率,使其能够在更短的时间内完成更多的计算。通过这种方式,处理器能够减少指令解码和数据处理的开销,从而提高整体性能。
“四个 y
值加上 3
” 的意思是,现代CPU或GPU在处理某些操作时并不会像我们在代码中看到的那样单独处理每个数据项。它们通常会一次性处理多个数据项。
以加法运算为例,如果有以下代码:
float x = y + 3;
如果 y
是一个数组或多个数据项,而不是一个单一的值,现代处理器会一次性将多个 y
的值与 3
相加。具体来说,处理器可能会一次性将 y[0]
、y[1]
、y[2]
和 y[3]
四个值分别加上 3
,并将结果保留在一个新的数据容器中。这样,处理器会同时对多个 y
值进行加法操作,而不是像传统的单个运算那样按顺序逐一处理。
这种方式是通过 SIMD 技术实现的,意味着同一条指令同时作用于多个数据点,从而加速计算过程。例如,处理器可能会同时计算 y[0] + 3
、y[1] + 3
、y[2] + 3
和 y[3] + 3
,然后将这四个结果放入一个寄存器或内存位置。这种处理方式大大提高了计算效率。
黑板:一个示例指令
在处理器中,一条指令通常涉及操作寄存器的内容。例如,可能会有一条指令要求将 r1
和 r2
寄存器中的内容相加,并将结果存储到 r0
寄存器中。这条指令的操作就是计算 r0 = r1 + r2
。
寄存器是处理器用来存储数据的高速存储区域,通常是处理器工作时用来存放最常用的数据集合。每个处理器有不同数量的寄存器,通常是16个或更多。指令流通常包括从内存加载数据到寄存器,进行操作,然后将结果从寄存器写回内存。
例如,一条完整的指令流可能如下:
- 从内存加载一个值到
r1
寄存器。 - 从内存加载另一个值到
r2
寄存器。 - 执行加法操作,将
r1
和r2
中的值相加,并将结果存储到r0
寄存器中。 - 将
r0
中的结果写回到内存。
这只是一个简单的指令流示例,目的是帮助理解在处理器内部实际上是如何执行这些计算的。每条指令都会涉及从内存读取数据、在寄存器中处理数据,并最终将结果写回内存的过程。
黑板:发出指令是昂贵的
处理器解码指令流的过程非常复杂。首先,处理器需要选择适当的寄存器来进行加法运算,并确保指令之间的依赖关系得到正确处理。例如,如果某条指令依赖于前一条指令的结果,处理器就无法并行执行这些操作,因为第二条指令需要等到第一条指令完成后才能执行。此外,处理器还需要考虑是否可以利用多个加法单元同时处理不同的加法操作,并选择空闲的运算单元来执行这些操作。
这一切都需要大量的电路和复杂的调度逻辑,因此解码指令流并决定如何执行每一条指令是非常昂贵的过程。为了优化这一过程,现代处理器通常会使用多个加法单元,并且能够并行执行多个加法操作。然而,尽管这些优化措施使得处理器能够更高效地执行任务,但它们仍然需要精密的协调和管理。
如果能够利用一次操作处理多个数据,这将大大减少处理器的工作量。例如,在处理器中,一次性加载四个数据比逐个加载四个数据要更有效,因为这样可以减少解码指令所需的内存带宽。对于加法操作来说,能够一次性加四个数,而不是执行四个单独的加法操作,也能显著提高效率。
这种优化方式不仅能减少处理器中的指令数量,还能减少处理器指令缓存的压力。由于每条指令需要的内存带宽更少,因此指令缓存能够更高效地存储和读取指令,减少了内存的访问压力,从而提升了整体的处理速度和性能。这种通过批量处理数据来优化处理器性能的方式,能够在计算密集型任务中带来显著的效益。
黑板:优化考虑
现代计算机, 无论是CPU还是GPU,都遵循一种类似的工作模式。这个模式的基本流程包括三个部分:指令、缓存和内存。在优化计算机性能时,必须考虑这三者之间的交互和数据传输。
首先,指令是CPU或GPU执行的操作。通常,这些指令涉及到将数据从内存加载到缓存中,再从缓存中加载到寄存器中进行计算。计算完成后,结果会被写回缓存,最终再写回到内存。这个过程是一个循环,数据在不同存储层次之间不断流动。
在执行过程中,指令是宽的,这意味着一条指令通常同时处理多个数据项。比如,一条指令可能同时加上四个数据项,这就是所谓的"单指令多数据"(SIMD)操作。这种方式能显著提高处理器的效率,因为它可以在同一周期内处理更多的数据。
总的来说,优化计算时需要关注的关键点是如何高效地将数据从内存移动到缓存,再到寄存器,进行计算后再返回。这是计算机系统中的常见模式,所有的优化工作都是围绕如何高效执行这个数据流动过程展开的。
黑板:内存访问成本
缓存是处理器上用于加速数据访问的存储区域,通常缓存有不同的层级,分别为L1、L2、L3等。每个层级的缓存距离CPU的寄存器越远,访问的成本也越高。寄存器(可以视为L0缓存)是处理器直接操作的内存,访问速度最快。接下来是L1、L2、L3缓存,分别是越来越大的存储区,位于CPU内部或较接近CPU的位置,访问速度逐渐降低,L1缓存通常需要大约16个时钟周期来访问,L2缓存更多,L3缓存再多,而主内存的访问时间可能达到300个时钟周期。
当执行指令(如加法指令)时,访问这些缓存的代价会逐渐增加,特别是当需要从更远的缓存或内存中提取数据时。指令执行的时间,例如加法可能是2个时钟周期,但随着数据来源的距离增加,整个过程的代价(延迟)也会更高。因此,在进行优化时,需要关注如何减少从远缓存或内存获取数据的延迟。
这个模型清晰地展示了处理器访问不同缓存层级的效率,并且对优化系统性能至关重要。
黑板:周期
CPU的工作是以周期为单位来进行的,每个周期代表处理器可以完成的一次操作。处理器的时钟速度决定了它每秒能够处理多少个周期。例如,一个3.2 GHz的处理器每秒钟大约会经历32亿次周期(3.2×10^9)。该频率意味着每个周期处理器可以执行一个操作。
为了衡量性能,通常以每秒多少帧来计算,在30帧每秒的情况下,处理器每帧所需要的周期数大约是1亿零700万个周期。如果在这一时间内,处理器不能完成所有需要的工作,游戏的帧率就会下降,无法维持30帧每秒的表现。因此,性能优化的一个关键点就是确保每一帧的操作可以在这些周期内完成,保证游戏流畅运行。
黑板:你应该始终知道你有多少个周期可用
在优化时,首先需要关注的一个关键数字是每帧的处理周期数。对于一个单核心的CPU,假设每秒有107百万个周期可用来完成任务。如果目标设备是多核处理器,那么每个核心都有这样的处理能力,因此,若工作能够分配到多个核心上,总的周期数将会更多。但在这里,我们首先讨论的是单个CPU核心的性能。
107百万个周期并不算多。以1920x1080的分辨率为例,屏幕上有约200万个像素。如果每个像素的渲染大约需要50个周期,那么对于每帧图像的渲染,实际使用的周期数并不多,甚至可能不足以完成复杂的操作。这个有限的周期数意味着在每帧的工作量中,每个任务必须非常精简和高效,避免超过这个限制,否则就会导致帧率下降、画面卡顿等问题。
所以,在进行优化时,必须确保所有操作都能在107百万个周期内完成,否则就会超出CPU的处理能力,影响性能和流畅度。
黑板:并非每个周期都可以使用
在考虑每帧的处理周期时,需要意识到,通常不会完全利用107百万个周期。这是因为在运行过程中,还会有一些任务发生,这些任务并不直接由应用控制。例如,调用Windows操作系统来显示帧时,系统需要处理一些其他任务,如读取操控杆状态等。这些任务也会消耗部分处理周期,因此可能无法完全用在应用程序的工作上。
此外,现代计算机往往具有多核心处理器。如果用户的计算机配置良好,后台没有占用过多资源的应用程序(如Adobe Acrobat或Creative Cloud等),那么在多个核心上,可能会有接近107百万个周期的计算能力可用。但通常,用于与操作系统接口的核心,不会完全拥有这些周期,可能会因为操作系统本身的处理需求而受到影响。
因此,在考虑优化时,需要了解并适应这些外部因素,明白并非所有的周期都能完全用于应用的工作负载。
黑板:什么是周期?
要理解CPU中的一个周期发生了什么,首先要了解CPU处理指令的基本过程。处理器通常会有一个指令缓存(I-cache),这个缓存用来存储和解码指令。我们编写的代码在编译后会被转换成机器指令,而这些机器指令会被加载到指令缓存中,并进一步解码为微代码(microcode)。微代码指令和我们看到的机器指令之间并不总是完全一一对应的。
在一个周期内,处理器首先从指令缓存中获取一批指令,数量可能是几条,也可能是更多,具体取决于处理器的设计。这些指令有时会按照顺序取出,但现代处理器通常会打破顺序,从缓存中出于优化目的按需取指。
一旦取出指令,处理器会将这些指令发出到不同的计算单元中,例如算术逻辑单元(ALU)或内存单元等。在这个过程中,指令的执行可能是乱序的,现代处理器通常支持乱序执行,这意味着处理器会在等待某些指令完成之前,先执行那些不依赖于其他指令结果的指令。例如,如果有一条指令依赖于前两条指令的结果,处理器就会等待它们完成,然后再执行依赖的指令;而那些不依赖于前面的指令的指令则会被立即执行。
这类优化手段(乱序执行)使得现代处理器能够高效地利用周期,尽量减少因等待依赖指令而浪费的时间。通过这种方式,处理器能够在一个周期内最大限度地执行多个指令,提高处理效率。
黑板:流水线阶段
在处理器的流水线中,指令发出后,会进入各个处理单元进行处理。这个流水线分为多个阶段,每个阶段需要一定的周期时间才能完成。每个指令在流水线中处理时,会经历多个步骤,通常包括取指、解码、执行等。如果执行的是加法操作(ADD),那么加法操作可能会经历两个阶段。
在第一个周期内,指令会进入一个空闲的算术逻辑单元(ALU),该单元会处理加法操作的第一个阶段。然后,在接下来的周期中,该指令会进入流水线的下一个阶段,继续处理加法操作的第二个阶段。最终,当所有阶段完成时,结果才会被写回。
所以,每一条指令的执行会涉及多个周期,这些周期对应流水线的各个阶段。比如,如果加法操作需要两个周期完成,那么在第一个周期中,指令会进入流水线的第一阶段;在第二个周期中,指令会进入第二阶段,最终在第三个周期完成并产生结果。
总之,每个周期都是流水线中的一个时钟周期,指令在流水线中按顺序完成不同的阶段,直到最终计算结果被写出。
黑板:为什么使用流水线?
流水线的目的是提高处理器的效率,使得不同部分的单元能够以更高的速率重复使用。这就像在洗衣和干衣过程中使用分开洗衣机和干衣机的方式。假设有一个洗衣机和干衣机,在第一个阶段,衣物被放入洗衣机。洗衣机完成后,衣物进入干衣机,而洗衣机可以立即开始处理下一批衣物。这样,在有限的时间内,多个洗衣操作可以同时进行,而不会浪费时间等待。
如果使用一台同时进行洗衣和干衣的设备,虽然也能完成同样的任务,但每次都需要一个完整的洗衣加干衣周期,这就导致了效率的降低。具体来说,使用两个分开的设备,可以在五个阶段内完成两批洗衣工作,而使用一个设备则需要六个阶段。在处理更多任务时,分开设备的优势更为明显,因为它每个阶段都在执行工作,不会有浪费的阶段。
通过流水线模型,处理器能够在不同的阶段之间重用硬件部件,充分利用计算资源。每个阶段的工作可以在处理器的多个单元中并行执行,从而加速处理过程。这样,处理器能够更有效地管理资源,减少等待时间,提高整体性能。
黑板:延迟和吞吐量
在计算机性能中,主要关注两个指标:延迟(latency)和吞吐量(throughput)。延迟是指一条指令从发出到完成的时间,也就是从发出指令到最终结果产生所经过的时间。而吞吐量则是指在流水线完全工作的情况下,能够处理多少条指令,即每单位时间内能完成多少任务。
在一个洗衣机和干衣机的例子中,延迟和吞吐量的概念也可以得到体现。例如,如果需要洗涤和干燥一批衣物,所需的时间无论是使用分开的洗衣机和干衣机,还是组合式洗衣干衣机,延迟都是相同的,因为每次洗涤和干燥都需要固定的时间。但如果是进行大量的洗衣,分开的洗衣机和干衣机在流水线方式下能够更高效地工作,因为每个阶段的设备可以同时工作,从而提高吞吐量。而组合式洗衣干衣机则在处理大量衣物时效率较低,因为它只能在每次完整周期后处理下一批。
对于计算机中的指令,吞吐量更加重要。通过流水线结构,处理器可以在每个周期内并行执行多个任务,因此在进行大规模的数据处理时,吞吐量决定了处理器的性能。而延迟通常对单个操作的影响较大,但在流水线化工作模式下,延迟的影响相对较小,因为处理器会并行处理多个任务,从而避免等待延迟。
通常,在性能优化时,更多关注的是吞吐量,而不是单个任务的延迟,因为我们假设代码会在流水线中持续运作,从而避免了延迟的积累。对于指令而言,吞吐量更能反映系统的整体处理能力,尤其是在处理大量指令时。
然而,延迟在某些情况下仍然重要,例如在访问缓存时,延迟可能会影响到数据的读取速度,这时需要特别关注如何优化缓存访问以减少延迟。
黑板:延迟会在哪些地方造成问题
在处理器的性能分析中,吞吐量和延迟是两个关键指标,特别是在内存访问和数据流动方面。吞吐量通常指的是内存或数据能够流经处理器的最大速度,通常以带宽(bandwidth)表示,而延迟则指的是从请求数据到获得数据的时间。
在内存系统中,吞吐量表示在理想情况下,内存数据能够以多快的速度通过处理器。带宽决定了处理器与内存之间的最大数据流动速度,这通常是以每秒多少GB来衡量的。内存带宽限制了处理器在单位时间内能够处理的数据量,因此它是衡量内存访问速度的一个重要指标。
延迟则是指从请求数据到数据返回所需的时间。例如,如果发出一次内存加载请求,且该请求需要300个周期才能完成,那么在这300个周期内,处理器其他指令将不得不等待数据的返回。这种延迟对于系统性能的影响非常大,因为它可能导致处理器在等待数据时闲置,无法进行其他计算操作。
通常,内存的延迟可能达到数百个周期,甚至在非常快速的内存系统中,延迟仍然可能是数十到数百周期。这意味着在高速处理器时代,内存的访问速度成为了性能瓶颈,尽管处理器的计算速度越来越快,内存访问却没有相应的提升。
总结来说,带宽和延迟在内存系统中起着至关重要的作用。带宽决定了数据流动的最大速度,而延迟则影响了每个请求的响应时间。两者共同决定了处理器能够有效执行任务的能力,尤其在需要频繁访问内存的应用中,优化带宽和延迟是提高性能的关键。
黑板:缓存未命中
缓存未命中(cache miss)是性能优化中的一个关键问题。缓存未命中发生在处理器请求的数据不在缓存中时,这时必须从主内存中加载数据,导致高延迟。相比之下,缓存的数据访问速度要快得多,L1缓存的访问延迟可能仅为2个周期,而L2缓存可能是16个周期左右。因此,如果代码能够完全在缓存中运行,避免访问主内存,那么就能够避免这些高延迟的缓存未命中,显著提高程序的执行速度。
为了优化缓存的使用,现代处理器支持预取(prefetch)指令。预取指令允许程序员告诉处理器,某些数据将在未来的操作中使用,因此希望提前将这些数据加载到缓存中。通过这样提前加载数据,可以在需要时避免等待内存访问,从而减少延迟。例如,某些操作可能需要300个周期才能访问内存,但如果通过预取指令提前加载所需的数据,处理器在执行其他操作时就能避免这些延迟,并在返回时直接从缓存中获取数据。
缓存管理和优化是现代计算中非常重要的一部分。通过合理利用缓存,尽可能多地在缓存中完成操作,并且优化何时将数据填充到缓存和何时刷新缓存,可以减少缓存未命中,避免高延迟。此外,现代处理器通过"乱序执行"(out-of-order execution)等技术来进一步优化性能。通过这种技术,处理器可以在等待某些数据加载的同时,执行其他不依赖于这些数据的操作,最大限度地减少空闲周期,从而提高整体处理效率。
黑板:超线程技术
超线程技术(Hyper-Threading)是一种处理器技术,通过在处理器内部保留两个独立的状态(例如状态0和状态1),使得处理器能够像拥有两个独立的处理器一样工作。每个状态都保持一套完整的处理器状态,允许处理器同时执行两个线程的任务。虽然这种技术看起来像是给处理器增加了两个线程,但实际上它并不是简单地复制两个处理器的状态,而是在处理器遭遇内存访问延迟时,通过切换到另一个状态来继续执行工作。
当处理器在执行某个任务时,如果由于内存延迟而无法继续执行,它会切换到另一个状态,查看是否能从中找到可执行的任务。如果第二个状态也因为内存延迟而停滞,它会再次切换回状态0。通过这种方式,超线程技术能够有效减少因内存延迟导致的处理器空闲时间。
超线程技术的主要目标是隐藏内存访问的延迟,因为内存相对于处理器来说要慢得多。这种延迟会形成“延迟气泡”,即处理器等待内存数据时无法执行其他任务。超线程通过在两个线程之间切换,尝试减少这些延迟气泡的影响,保持处理器的高效运转。
除此之外,现代处理器还依赖于程序员主动发出预取指令、写入缓存等手段来提高缓存的使用效率,以进一步减小内存访问延迟的影响。处理器的缓存一致性和高效的缓存管理对于优化程序性能至关重要,虽然这些技术在硬件上有所支持,但最终还是需要程序员在编程中合理利用这些工具来提升程序的整体性能。
黑板:优化,平台
优化的目标是通过合理设计指令集,确保代码的指令流能够最大化地利用CPU的缓存,同时尽量避免过大的指令集占用过多的指令缓存。指令流的设计需要尽量小,以便在CPU的缓存中高效运行,避免因缓存溢出导致性能下降。
为了实现这一目标,需要提前了解程序将使用哪些内存,并在可能的情况下提前将数据填充到缓存中。代码还需要能够有效地拆分,利用超线程和多个处理器(如果有的话)来最大化CPU的处理能力。在这种设计中,每一条指令的执行都应该尽可能填满CPU的流水线,以确保处理器在每个周期内都能高效工作,从而最大限度地利用每个周期,提升代码的执行效率。
优化过程中,有些代码主要依赖于内存的管理,如何高效地将内存数据传递到CPU并避免内存访问延迟;而有些代码则侧重于指令的管理,关键在于如何高效地安排指令的执行顺序。但大部分情况下,优化需要同时关注内存管理和指令管理。首先要确保内存访问不会导致处理器停滞,之后则要尽可能减少处理器对内存的工作量,以保持高效的运算。
总之,优化的核心目标是让程序在处理数据时最大化地利用缓存和CPU的执行能力,减少不必要的内存访问,并确保指令流尽可能高效地运行,以获得更高的性能。
黑板:这就是优化
在讨论优化时,首先会集中在如何编写高效的代码,使其运行速度尽可能快。然而,还有一种附加的概念,这并不是这次讨论的重点。接下来的讨论将从优化的基本层面开始,主要关注如何通过合理的代码设计提升程序的执行效率。
黑板:效率
在讨论优化时,性能和效率是两个关键的概念。性能优化是最复杂的,它涉及大量的领域知识,尤其是处理器的工作原理。性能优化的目标是让特定的算法在处理器上尽可能高效地运行,这通常需要深入理解处理器的架构和复杂的优化技术。
而效率优化则更容易理解,重点是减少不必要的工作。效率意味着通过设计更好的算法,避免执行不必要的计算。例如,当处理一个三角形时,直接检查其边界框内的像素就比检查整个屏幕的所有像素要高效得多。这是一个典型的效率优化案例,算法本身更高效,执行的工作量更少,从而减少了不必要的计算。
效率优化通常比性能优化更为重要,因为即使在性能优化方面做得再好,如果算法本身效率低下,那么优化也不会有太大意义。在进行性能优化之前,确保算法的效率已经合理是至关重要的。
总的来说,效率优化关注的是做最少的工作以获得所需结果,而性能优化则是通过合理的数据结构和操作安排,确保数据能在处理器上以最少的周期移动。通常,性能优化的目标是尽可能达到最佳效果,但并不一定要求达到极限。大部分情况下,优化目标是达到一个合理的、接近最佳的状态,而不是追求完美的性能。
你会愿意做更多的黑板讲解吗?这非常有帮助
当有需要解释的概念时,会制作更多的黑板教学视频。如果之前的某个话题没有讲解够详细,可以提出,之后可以专门做一个黑板课程。每当在游戏开发过程中遇到需要解释的地方,都会制作黑板视频,过去也曾多次在需要的时候做过黑板教学。因此,黑板教学是根据实际需要随时制作的,不会遗漏任何重要的解释。
你会使用像 VTune 这样的工具来衡量性能吗?
可能不会使用像 vtune 这样的工具来衡量性能。大概会使用自带的时间戳计数器(DTSC)来进行性能测量。因为 vtune 仍然需要支付高额费用(大约 $500),而目的是让观众能够使用自己能负担得起的工具。所以,计划写一些自定义的工具来进行测量,避免依赖昂贵的软件,除非它现在免费提供。
指令是如何写入缓存内存的?
对于微代码的具体格式,没有确切的了解。这可能是一个很好的问题,可以向英特尔的专家询问。虽然编程了 30 年,但从未见过有人讨论微代码的实际格式,可能这是英特尔的商业机密。也许通过搜索 “Intel microcode format” 或 “x86 microcode format” 可以找到一些相关的信息,可能在英特尔的系统架构手册中有提到。但至今未见相关内容,也没有专门去寻找过这个问题的答案。如果它是公开的,可能会有一些文档讨论这个问题。
我们需要手动发出预取指令,还是CPU会根据我们访问内存的方式自动推测?
预取操作可以是手动的,也可以由 CPU 根据内存访问模式推断出来。在此图示中讨论的是手动预取。早在奔腾处理器时代(可能是 P6 或 P5 版本)就引入了手动预取指令。如果编写的代码频繁发生缓存未命中的问题,而 CPU 无法正确预测所需的内存,就可以手动插入预取指令。例如,可以在循环的某部分插入指令,告诉 CPU 提前预取未来几次迭代中需要的数据,这样 CPU 就能更快地获取所需数据。
处理器还提供了非临时存储等其他指令,适用于解决缓存问题。对于普通优化者而言,这些指令(如预取和非临时存储)足以解决基本的缓存问题。但对于 hardcore 优化者来说,可能会希望有更多的控制来优化缓存,如缓存别名(cache aliasing)等复杂问题。
有些平台(例如 PlayStation 3 上的 Cell 处理器)提供了极高的缓存控制能力,程序员可以完全掌控缓存的管理,选择何时、何地移动数据。这种控制力能够实现更精细的优化,尽管现代 Intel 处理器未必提供如此细粒度的控制。
你如何估算实现一个特性之前实际工作的量与实现后测量的量之间的关系?
在实现一个功能之前,通常会进行一些计算和分析,以了解该功能应该达到的峰值速度,并设定一个目标速度。这包括估算处理器的周期数、所需的操作数以及可能的优化点。进行这些初步计算后,可以得出一个大致的速度预期,然后将实际的性能与这个预期进行对比。如果发现实际速度与预期有较大差距,可以进一步分析可能的原因,并寻求硬件厂商或优化专家的帮助。
这种方法有助于明确性能目标,并为后续的优化工作提供参考。通过与预期进行对比,可以识别出不符合预期的地方,并探讨优化方案。对于一些硬核优化人员,他们可能会联系硬件厂商,获取更多的内部信息来进一步分析和优化性能。
如果内存访问需要几百个周期,而指令需要访问硬盘,这会有什么影响?
随着SSD的普及,硬盘对性能的影响变得较小,但硬盘访问依然是一个显著的延迟来源。可以将系统的存储层次结构视为一个逐步增加延迟的过程,起始于寄存器,然后是L1、L2、L3缓存,再到内存、硬盘和网络等。随着访问层级的增加,延迟也逐渐增大,从几百个周期到成千上万个周期不等。
在处理硬盘访问时,延迟变得尤为重要,因此在涉及到硬盘读取时,通常会采取重叠操作的方式。例如,在工作中会提前两秒钟甚至更多时间发起硬盘读取请求,这样可以确保数据在需要时已经准备好,从而避免延迟对性能的影响。
两个问题:1) 是否有情况需要担心我们的某条指令被解码为多个微代码指令,而我们无法察觉?
在进行优化时,通常不需要过多关注微指令的细节,因为对于大多数优化工作来说,关注吞吐量和延迟的数字就足够了。然而,对于极端优化的情况,确实需要关注微指令的拆分问题。当一条指令被拆解成多个微指令时,可能会对指令的发射产生压力。例如,如果某条指令被拆解成七个微指令,并且这些微指令之间是串行依赖的,那么发射这些指令所需的周期数会增加,可能会导致性能下降。尤其是在没有能够隐藏这些延迟的代码时,这种情况会变得尤为严重。
但这种问题并不常见,通常情况下,不需要过于担心微指令的拆分。如果只是进行一般的优化工作,通常不会碰到这种极限性能的问题,因此不需要考虑微指令的细节。
2) 在优化时,你是否设置了代码结构,使得可以按功能逐个优化,还是我们需要重构一些函数才能进行优化?
在优化时,一般会按函数逐个优化,这是一个很好的做法。编写代码时,应当清楚哪些操作是昂贵的,关键是要考虑时间复杂度。例如,循环的嵌套会导致更多的迭代,因此需要关注最耗时的部分。在编写软件渲染器时,像像素填充这种操作通常会占用大量时间,因为它涉及到大范围的循环操作。
在实际编写代码时,通过考虑复杂度,可以明确哪些部分是性能瓶颈。例如,在渲染中,像素填充的区域会比较大,而精灵的数量相对较少,这使得填充操作成为最重要的优化目标。因此,在设计代码时,应当围绕这些瓶颈进行优化。
另外,虽然面向对象编程(OOP)具有一定的优点,但它的代码结构通常与优化需求不符。通常,写在面向过程的风格中的C语言代码,会更接近优化后的结构,因为C语言结构体的设计相对简单,更适合于优化。而OOP风格的代码往往更复杂,需要更多的重组才能满足优化的需求。因此,选择C风格编程可以帮助自然地写出更容易优化的代码。
将缓存卸载到SSD上,使用最少的RAM,是否会低效,还是延迟会太高?
对于将缓存卸载到SSD的问题,SSD基本上相当于非常慢的内存,位于内存层次结构的较低层次。因此,通常不会直接将数据卸载到SSD,而是先卸载到内存,再将数据写入SSD。这是因为将数据写入SSD时,通常需要与内存操作重叠,以减少延迟。如果将缓存直接卸载到SSD,延迟可能过大,导致效率低下。因此,卸载缓存的顺序通常是先到内存,再到SSD。
“过早优化是万恶之源。”你对此有何看法?
“过早优化是万恶之源”这个说法实际上并不完全准确。问题的关键在于“过早”优化究竟指的是什么。表面上看,优化太早确实会浪费时间,因为如果游戏已经能够以30帧每秒运行,那么在指令级别进行优化就显得毫无意义,并且可能会使代码更加脆弱和难以维护。但真正的问题在于,有些优化决策并不简单。比如,是否需要将数据存储在空间层次结构中,这种决策可能对后续的代码架构产生深远影响。延迟做出决策,可能会导致后续需要大规模重构代码,这时优化就显得为时已晚。因此,判断优化是否过早是非常困难的,并不像这句格言所表达的那样简单。
有办法利用或避免超线程技术以获得优势吗?
可以通过使用超线程和常规线程来优化渲染器的性能,下一次流媒体演示中将讨论如何在渲染器中使用这两种方式。
你会怎么告诉一个不喜欢 Emacs 的人?
如果有人不喜欢 Emacs,可以建议他们不要使用 Emacs,毕竟每个人都有自己的舒适编辑器。关键是无论使用哪个编辑器,都能达到相当的编程速度。如果不能以合适的速度编程,可以考虑提高编辑器的使用熟练度,或者切换到一个更适合的编辑器。最终目标是确保能够在编程时保持流畅和高效,如果速度明显慢于期望,可能需要反思自己对编辑器的掌握程度。
超线程是否减少最大带宽,因为它必须在状态之间切换,还是两个状态可以同时操作?
超线程并不会增加带宽,因为它们共享带宽。带宽的定义是处理器能够接收和处理数据的量,因此无论是否使用超线程,带宽的上限是固定的。如果有两个超线程,它们会共享这条带宽,而不会因此获得更高的带宽。所以,超线程并不会提高带宽,反而可能会因为共享带宽而降低整体性能。
根据你的经验,是什么驱动了“足够好”的优化,初学者如何掌握这一点?
“足够好”的优化原则是相对直接的。游戏需要保持每秒30帧的运行效果,如果某些操作较为耗费资源,达到30帧/秒的目标就可以认为优化完成了。判断是否达成目标其实很简单,就是看游戏是否能在目标平台上顺利运行,若能稳定在30帧/秒,那就算完成优化。
对于每个具体的操作,衡量其是否接近最优是通过一些粗略的估算来完成的。一般来说,需要先了解理想情况下处理器能多快完成这个工作,然后与实际的工作速度对比。这种计算能有效地衡量性能是否优化到位。
至于效率的优化,情况就复杂得多。效率很难准确判断,因为如何确认一个算法是否高效并不总是显而易见。有时可以通过已知的理论证明某算法的复杂度是O(n),从而知道算法在理论上是高效的。但有时实际情况很复杂,例如算法可能借助查找结构来加速,使得在大多数情况下复杂度接近O(1),但有些情况下可能依然不够高效,可能还需要调整空间划分等。效率优化常常涉及到大量的细节和反复的迭代。
因此,性能优化往往是一个不断试错的过程,直到能稳定达到每秒30帧。这个过程没有捷径,必须通过不断调整和反复测试才能最终找到最佳解决方案。
结束总结
优化的讨论已经完成,接下来将进入编程部分,计划尽快开始代码的编写。在这之前,已经通过一场黑板式的讲解讨论了优化的相关主题。通过这次讲解,希望大家能够理解一些关键概念,比如L3缓存、指令延迟、吞吐量等。