编译反馈优化(PGO)

原文: 字节跳动在PGO反馈优化技术上的探索与实践

PGO(Profile-guided optimization)通常也叫做 FDO(Feedback-directed optimization),它是一种编译优化技术,它的原理是编译器使用程序的运行时 profiling 信息,生成更高质量的代码,从而提高程序的性能。

传统的编译器优化通常借助于程序的静态分析结果以及启发式规则实现,而在被提供了运行时的 profiling 信息后,编译器可以对应用进行更好的优化。通常来说编译反馈优化能获得 10%-15% 的性能收益,对于特定特征的应用(例如使用编译反馈优化 Clang本身)收益高达 30%。

编译反馈优化通常包括以下手段:

  1. Inlining,例如函数 A 频繁调用函数 B,B 函数相对小,则编译器会根据计算得出的 threshold 和 cost 选择是否将函数 B inline 到函数 A 中。
  2. ICP(Indirect call promotion),如果间接调用(Call Register)非常频繁地调用同一个被调用函数,则编译器会插入针对目标地址的比较和跳转指令。使得该被调用函数后续有了 inlining 和更多被优化机会,同时增加了 icache 的命中率,减少了分支预测的失败率。
  3. Register allocation,编译器能使用运行时数据做更好的寄存器分配。
  4. Basic block optimization,编译器能根据基本块的执行次数进行优化,将频繁执行的基本块放置在接近的位置,从而优化 data locality,减少访存开销。
  5. Size/speed optimization,编译器根据函数的运行时信息,对频繁执行的函数选择性能高于代码密度的优化策略。
  6. Function layout,类似于 Basic block optimization,编译器根据 Caller/Callee 的信息,将更容易在一条执行路径上的函数放在相同的段中。
  7. Condition branch optimization,编译器根据跳转信息,将更容易执行的分支放在比较指令之后,增加icache 命中率。
  8. Memory intrinsics,编译器根据 intrinsics 的调用频率选择是否将其展开,也能根据 intrinsics 接收的参数优化 memcpy 等 intrinsics 的实现。

编译器需要 profiling 信息对应用进行优化,profile 的获取通常有两种方式:

  • Instrumentation-based(基于插桩)
  • Sample-based(基于采样)

Instrumentation

Instrumentation-based PGO 的流程分为三步骤:

  • 编译器对程序源码插桩编译,生成插桩后的程序(instrumented program)。
  • 运行插桩后的程序,生成 profile 文件。
  • 编译器使用 profile 文件,再次对源码进行编译。

Instrumentation-based PGO 对代码插桩包括:

1. 插入计数器(counter)

  • 对编译器 IR 计算 MST,计算频繁跳转的边,对不在 MST 上的边插入计数器,用于减少插桩代码对运行时性能的影响。
  • 在函数入口插入计数器。

2. 插入探针(probes)

  • 收集间接函数调用地址(indirect call addresses)。
  • 收集部分函数的参数值。

Sampling

Sample-based PGO 的流程同样分为三步骤:

  • 编译器对程序源码进行编译,生成带调试信息的程序(program with debug information)。
  • 运行带调试信息的程序,使用 profiler(例如linux perf)采集运行时的性能数据。
  • 编译器使用 profile 文件,再次对源码进行编译。

其中步骤2采集的数据为二进制级别采样数据(例如 linux perf 使用 perf record 命令收集得到 perf.data 文件)。二进制采样数据通常包含的是程序的 PC 值,我们需要使用工具,读取被采样程序的调试信息(例如使用 AutoFDO 等工具),将程序的原始二进制采样数据生成程序源码行号对应的采样数据,提供给编译器使用。

比较

对比 sampled-based PGO,Instrumentation-based PGO 的优点采集的性能数据较为准确,但繁琐的流程使其在业务上难以大规模落地,主要原因有以下几点:

  • 应用二进制编译时间长,引入的额外编译流程影响了开发、版本发布的效率。
  • 产品迭代速度快,代码更新频繁,热点信息与应用瓶颈变化快。而 instrumented-based PGO 无法使用旧版本收集的 profile 数据编译新版本,需要频繁地使用插桩后的最新版本收集性能数据。
  • 插桩引入了额外的性能开销,这些性能开销会影响业务应用的性能特征,收集的 profile 不能准确地表示正常版本的性能特征,从而降低优化的效果,使得 instrumented-based PGO 的优点不再明显。

使用 Sample-based PGO 方案可以有效地解决以上问题:

  • 无需引入额外的编译流程,为程序添加额外的调试信息不会明显地降低编译效率。
  • Sample-based PGO 对过时的 profile 有一定的容忍性,不会对优化效果产生较大影响。
  • 采样引入的额外性能开销很小,可以忽略不计,不会对业务应用的性能特征造成影响。

POSIX 线程同步——条件变量

POSIX多线程程序设计. 作者 美 David R.Buten

条件变量是用来通知共享数据状态信息的。可以使用条件变量来通知队列已空、或队列非空、或任何其他需要由线程处理的共享数据状态。

当一个线程互斥地访问共享状态时,它可能发现在其他线程改变状态之前它什么也做不了。状态可能是对的和一致的,即没有破坏不变量,但但是线程就是对当前状态不感兴趣。例如,一个处理队列的线程发现队列为空时,它只能等待,直到有一个节点被填加进队列中。

例如,共享数据由一个互斥量保护。线程必须锁住互斥量来判定队列的当前状态,如判断队列是否为空。线程在等待之前必须释放锁(否则)其他线程就不可能插入数据),然后等待队列状态的变化。例如,线程可以通过某种方式阻塞自己,以便插入线程能够找到它的ID并唤醒它。但是这里有一个问题,即线程是运行于解锁和阻塞之间。

如果线程仍在运行,而其他线程此时向队列中插入了一个新的元素,则其他线程无法确定是否有线程在等待新元素。等待线程已经查找了队列并发现队列为空,解锁互斥量,然后阻塞自己,所以无法知道队列不再为空。更糟糕的是,它可能没有说明它在等待队列非空,所以其他线程无法找到它的线程ID,它只有永远等下去了。解锁和等待操作必须是原子性的,以防止其他线程在该线程解锁之后、阻塞之前锁住互斥量,这样其他线程才能够唤醒它。

等待条件变量总是返回锁住的互斥量。

这就是为什么要使用条件变量的原因。条件变量是与互斥量相关、也与互斥量保护的共享数据相关的信号机制。在一个条件变量上等待会导致以下原子操作:释放相关互斥量,等待其他线程发给该条件变量的信号(唤醒一个等待者)或广播该条件变量(唤醒所有等待者)。当等待条件变量时,互斥量必须治终锁住:当线程从条件变量等待中醒来时,它重新继续锁住互斥量。

条件变量不提供互斥。需要一个互斥量来同步对共享数据(包括活等待的谓词)的访问,这就是为什么在等待条件变量时必须指定一个互斥量。通过将解锁操作与等待条件变量原子化,Pthreads系统确保了在释放互斥量和等待条件变量之间没有线程可以改变与条件变量相关的“谓词”(如队列满或者队列空)。

为什么不将互斥量作为条件变量的一部分来创建呢?首先,互斥量不仅与条件变量一起使用,而且还要单独使用;其次,通常一个互斥量可以与多个条件变量相关。例如,队列可以为空,也可以为满。虽然可以设置两个条件变量让线程等待不同的条件,但只能有一个互斥量来协调对队列头的访问。

一个条件变量应该与一个谓词相关。如果试图将一个条件变量与多个谓词相关,或者将多个条件变量与一个谓词相关,就有陷入死锁或者竞争问题的危险。只要小心使用,可能不会有什么问题,但是很容易搞混你的程序,并且通常也不值得冒险。原则是:第一,当你在多个谓词之间共享一个条件变量时,必须总是使用广播,而不是发信号;第二,信号要比广播有效。

条件变量和谓词都是程序中的共享数据。它们被多个线程使用,可能是同时使用。由于你认为条件变量和谓词总是一起被锁定的,所以容易让人记住它们总是被相同的互斥量控制。在没有锁住互斥量前就发信号或广播条件变量是可能的(合法的,通常也是合理的),但是更安全的方式是先锁住互斥量。

下图显示了三个线程与一个条件变量交互的时间图。圆形框代表条件变量,三条线段代表三个线程的活动。

当线段进入圆形框时,既表明线程使用条件变量做了一些事。当线程对应的线段在到达圆形框中线之前停止,表明线程在等待条件变量;当线程线段到达中线之下时,表明它在发信号或广播来唤醒等待线程。

线程1等条件变量发信号,由于此时没有等待线程,所以没有任何效果。线程1然后在条件变量上等待。线程2同样在条件变量上阻塞,随后线程3发信号唤醒在条件变量上等待的线程1。线程3然后在条件变量上等待。线程1广播条件变量,唤醒线程2和线程3。随后,线程3在条件变量上等待。一段时间后,线程3的等待时间超时,线程3被唤醒。

1 创建和释放条件变量

pthread_cond_t cod = PTHREAD_COND_INITIALIZER
int pthread_cond_init (pthread_cond_t *cond,
    pthread_condattr_t *condattr);
int Pthread_cond_destroy (pthread_cond_t *ccond)

程序中由pthread_cond_t类型的变量来表示条件变量。永远不要拷贝条件变量,因为使用条件变量的备份是不可知的,这就像是打一个断线的电话号码并等待回答一样。例如,一个线程可能在等待条件变量的一个拷贝,同时其他线程可能广播或发信号给该条件变量的其他拷贝,则该等待线程就不能被唤醒。不过,可以传递条件变量的指针以使不同函数和线程可以使用它来同步。

大部分时间你可能在整个文件范围内(即不在任何函数内部)声明全局或静态类型条件变量。如果有其他文件需要使用,则使用全局(extern)类型;否则,使用静态(static)类型。如下面实例cond_static.c所示,如果声明了一个使用默认属性值的静态条件变量,则需要使用PTHREAD_COND_INITIALIZER宏初始化。

#include <pthread.h>
#include "errors.h"
/*
 * Declare a structure, with a mutex and conddition variable
 * statically initialized. This is the same asusing
 * pthread_mutex_init and pthread_cond_init, with the ddefault
 * attributes.
 */
typedef struct_my_struct_tag {
    pthread_mutex_t mutex; /* Protects access to value */
    pthread_cond_t cond; /* Signals change to value */
    int value; /* Access protected by mutex */
} my_struct_t;
my_struct_t data = {
    PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER0};
int main (int argc, char *argv[]){
    return 0;
}

当声明条件变量时,要记住条件变量与相关的谓词是”链接”在一起的。建议你将一组不变量、谓词和它们的互斥量,以及一个或多个条件变量封装为一个数据结构的元素,并仔细地记录下它们之间的关系。

有时无法静态地初始化一个条件变量,例如,当使用malloc分配一个包含条件变量的结构时。这时,你需要调用pthread_cond_init来动态他初始化条件变量,如以下实例cond_dynamic.c所示。还可以动态初始化静态声明的条件变量,但是必须确保每个条件变量在使用之前初始化且仅初始化次。你可以在建立任何线程前初始化它,或者使用pthread_once。如果需要使用非默认属性初始化条件变量,必须使用动态初始化。

#include <pthread.h>
#include "errors.h"
/*
 * Define a structure, with a mutex and condittion variable.
 */
typedef struct_my_struct_tag {
    pthread_mutex_t mutex; /* Protects access to value */
    pthread_cond_t cond; /* Signals change to value */
    int value; /* Access protected by mutex */
} my_struct_t;
int main (int argc, char *argv[]) {
    my_struct_t *data;
    int status;
    data = malloc (sizeof (my_struct_t));
    if (data == NULL)
        errno_abort ("Allocate structure");
    status = pthread_mutex_init (&data->mutex, NULL);
    if (status != 0)
        err_abort (status, "Init mutex");
    status = pthread_cond_init (&data->cond, NULL);
    if (status != 0)
        err_abort (status, "Init condition");
    status = pthread_cond_destroy (&data->cond);
    if (status != 0)
        err_abort (status, "Destroy condition");
    status = pthread_mutex_destroy (&data->mutex);
    if (status != 0)
        err_abort (status, "Destroy mutex");
    (void)free (data);
    return status;
}

当动态初始化条件变量时,应该在不需要它时调用pthread_coind_destroy来释放它。不必释放一个通过PTHREAD_COND_INITIALIZER宏静不态初始化的条件变量。

当你确信没有其他线程在某条件变量上等待,或者将要等待、发信号或广播时,可以安全地释放该条件变量。判定上述情况的最好方式是在刚刚成功地广播了该条件变量、唤醒了所有等待线程的线程内,且确信不再有线程随后后使用它时安全释放。

2 等待条件变量

int pthread_cond_wait (pthread_cond_t *coned,
    pthread_mutex_t *mutex);
int pthread_cond_timedwait (pthread_cond t *cond, 
    pthread_mutex_t *mutex, struct timespec *expiration);

每个条件变量必须与一个特定的互斥量、一个谓词条件相关联。当线程等待条件变量时,它必须将相关互斥量锁住。记住,在阻塞线程之前,条件变量等待操作将解锁互斥量;而在重新返回线程之前,会再次锁住互斥量。

所有并发地(同时)等待同一个条件变量的线程必须指定同一个相关互斥量。例如,Pthreads不允许线程1使用互斥量A等待条件变量A,而线程2使用互斥量B等待条件变量A。不过,以下情况是十分合理的:线程1使用互斥量A等待条件变量A,而线程2使用互斥量A等待条件变量B。即有任何条件变量在特定时刻只能与一个互斥量相关联,而互斥量则可以同时与多个条件变量关联。

在锁住相关的互斥量之后和在等待条件变量之前,测试谓词是很重要的。如果线程发信号或广播一个条件变量,而没有线程在等待该条件变量时,则什么也没发生。如果在这之后,有线程调用pthread_cond_wait,则它将一直等待下去而无视该条件变量刚刚被广播的事实,这将意味着该线程可能永远不被唤醒。因为在线程等待条件变量之前,互斥量一直被锁住,所以,在测试谓词和等待条件变量之间无法设置谓词——互斥量被锁住,没有其他线程可以修改共享数据,包括谓词。

当线程醒来时,再次测试谓词同样重要。应该总是在循环中等待条件变量,来避免程序错误、多处理器竞争和假唤醒。以下实例cond.c,显示了如何等待条件变量。

wait_thread 线程睡眠一段时间以允许主线程在被唤醒之前条件变量等待操作,设置共享的谓词(data.value),然后发信号给条件变量。 wait_thread线程等待的时间由hibernation变量控制,默认是1秒。

如果程序带参数运行,则将该参数解析为整数值,保存在hibernation变量中。这将控制wait_thread线程在发送条件变量的信号前等待的时间。

主线程调用pthread_cond_timedwait函数等待至多2秒(从当前时间开始)。如果hibernation变量设置为大于两秒的值,则条件变量等待操作将超时,返回ETIMEDOUT。如果hibernation变量设为2秒,则主线程和wait_thread线程发生竞争,并且每次运行的结果可能不同。如果hibernation变量设置为小于2秒,则条件变量等待操作不会超时。

#include <pthread.h>
#include "errors.h"

typedef struct_my_struct_tag {
    pthread_mutex_t mutex; /* Protects access to value */
    pthread_cond_t cond; /* Signals change to value */
    int value; /* Access protected by mutex */
} my_struct_t;
my_struct_t data = {
    PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER0};

int hibernation = 1; /* Default to 1 second */
/*
 * Thread start routine. It will set the mainthread's predicate
 * and signal the condition variable.
 */
void * wait_thread (void *arg) {
    int status;
    sleep (hibernation);
    status = pthread_mutex_lock (&data.mutex);
    if (status != 0)
        err_abort (status, "Lock mutex");
    data.value = 1; /* Set predicate */
    status = pthread_cond_signal (&data.cond);
    if (status != 0)
        err_abort (status, "Signal condition");
    status = pthread_mutex_unlock (&data.mutex);
    if (status != 0)
        err_abort (status, "Unlock mutex");
    return NULL;
}

int main (int argc, char *argv[]) {
    int status;
    pthread_t wait_thread_id;
    struct timespec timeout;
    /* 
     * If an argument is specified, interpret it asthe number
     * of seconds for wait thread to sleep before signaling the
     * condition variable. You can play with this to see the
     * condition wait below time out or wake normally.
     */
    if (argc > 1)
        hibernation = atoi (argv[1]);
    /*
     * Create wait_thread.
     */
    status = pthread_create (
        &wait_thread_id, NULL, wait_thread, NULL);
    if (status != 0)
        err_abort (status, "Create wait thread");

    /*
     * Wait on the condition variable for 2 seconnds, or until
     * signaled by the wait_thread. Normally, wait_tthread
     * should signal. If you raise "hibernation" above 2
     * seconds, it will time out.
     */
    timeout.tv sec = time (NULL) + 2;
    timeout.tv nsec = 0;
    status = pthread_mutex_lock (&data.mutex);
    if (status != 0)
        err_abort (status, "Lock mutex");
    while (data.value == 0) {
        status = pthread_cond_timedwait (
            &data.cond, &data.mutex, &timeout);
        if (status == ETIMEDOUT) {
            printf ("Condition wait timed out.\n");
            break;
        }
        else if (status != 0)
            err_abort (status, "Wait on condition");
    }
    if (data.value 1= 0)
        printf ("Condition was signaled.\n");
    status = pthread_mutex_unlock (&data.mutex)1
    if (status != 0)
        err_abort (status, "Unlock mutex");
    return 0;
}

pthread_cond_wait函数是POSIX线程库中用于等待条件变量的函数之一。它的原理涉及到线程同步和互斥锁的概念。

在调用pthread_cond_wait之前,通常需要先获取一个互斥锁(pthread_mutex_lock),以确保在等待条件变量期间的线程安全性。然后,线程会检查一个条件,如果条件不满足,线程就会阻塞在pthread_cond_wait调用处,等待其他线程发出条件变量的信号。

当其他线程调用pthread_cond_signal或pthread_cond_broadcast函数时,条件变量会被发出信号。这些函数用于通知等待在条件变量上的线程,条件已经满足,或者在广播情况下,通知所有等待线程。此时,被阻塞的线程会被唤醒,并开始重新尝试获取互斥锁。

pthread_cond_wait的原理可以简述如下:

  1. 线程调用pthread_mutex_lock获取互斥锁,确保线程安全。
  2. 线程检查条件是否满足。如果条件满足,线程不会调用pthread_cond_wait,而是继续执行后续操作。
  3. 如果条件不满足,线程调用pthread_cond_wait,释放互斥锁并进入阻塞状态,等待条件变量的信号。
  4. 其他线程调用pthread_cond_signal或pthread_cond_broadcast发出条件变量的信号。
  5. 被阻塞的线程被唤醒,重新尝试获取互斥锁。
  6. 线程成功获取互斥锁后,继续执行后续操作。

需要注意的是,pthread_cond_wait函数的阻塞和唤醒是由操作系统内核实现的,因此具体的实现细节可能因操作系统而异。但是,POSIX线程库提供了一种标准接口,确保了跨平台的可移植性。

POSIX 线程同步——互斥量

POSIX多线程程序设计. 作者 美 David R.Buten

大部分多线程程序需要在线程间共享数据。如果两个线程同时访问共享数据就可能会有问题,因为一个线程可能在另一个线程修改共享数据的过程中使用该数据,并认为共享数据保持未变。

使线程同步最通用和常用的方法就是确保对相同(或相关)数据的内存访问”互斥地”进行,即一次只能允许一个线程写数据,其他线程必须等待。Pthreads使用了一种特殊形式的信号灯——互斥量。互斥量(mutex)是由单词互相(mutual)的首部”mut”和排斥(eexclusion)的首部”ex”组合而成的。

同步不仅仅在修改数据时重要,当线程需要读取其他线程写入的数据时,而且数据写入的顺序也有影响时,同样需要同步。

考虑以下实例:一个线程向数组中某个元素填加新数据,并更新max_index变量以表明该数组元素有效。在另一个处理器上同时运行的处理线程,负责遍历数组并处理每个有效元素。如果处理线程在读取数组元素的新数据:之前先读取了更新后的max_index值,计算就会出错。这可能显得有些不太合理,但是以这种方式工作的内存系统比按照确定顺序访问内存的系统要快得多。互斤量是解决此类问题的通用方法:在访问共享数据的代码段周围加锁互斥量,则一次只能有一个线程进入该代码段。

下图显示了共享互斥量的三个线程的时序图。处于标有”互斥量”的圆形框之上的线段表示相关的线程没有拥有互斥量。处于圆形框中心线之下的线段表示相关的线程拥有互斥量。处于中心线之上的线段表明相关的线程等待拥有互斥量。

最初,互斥量没有被加锁。当线程1试图加锁该互斥量时,由于于没有竞争,线程1立即加锁成功,对应线段也移到中心线之下。然后线程2试图加锁互斥量,由于互斥量已经被锁住,所以线程2被阻塞,对应线段在中心线之上。线程1解锁互斥量,解除线程2的阻塞,使其对互斥量加锁成功。稍后,线程3试图加锁互斥量被阻塞。线程1调用函数pthread_mutex_trylock试着锁住互斥量,而立刻返回EBUSY。线程2解锁互斥量,解除线程3的阻塞,线程3加锁成功。最后,线程3完成工作,解锁互斥量。

1 创建和销毁互斥量

pthread_mutex_t_mutex = PTHREAD_MUTEX_INITTALIZER;
int pthread nutex init(
    pthread_mutex_t *mutex, pthread_mutexattr_t*attr);
int Pthread_mutex_destroy (Pthread_mutex_t*mutex);

程序中的互斥量是用pthread_mutex_t类型的变量来表示的。不能拷贝互斥量变量,因为使用拷贝的互斥量是不确定的。可以拷贝指向互斥量的指针,这样就可以使多个函数或线程共享互斥量来实现同步。

大部分时间你可能在”文件范围”内,即函数体外,声明互斥量为外部或静态存储类型。如果有其他文件使用互斥量,则将其声明为外部类型;如果仅在本文件内使用,则将其声明为静态类型。你应该使用宏 PTHREAD_MUTEEX_INITIALIZER 来声明具有默认属性的静态互斥量,如下面例程mutex_staltic.c所示(你可以编译并运行该程序,不过因为main函数为空,所以不会有任何结果)。

#include <pthread.h>
#include "errors,h"
/*
 * Declare a structure, with a mutex, statically initialized. This
 * is the same as using pthread_mutex_init, withthe default,
 * attributes.
 */
typedef struct my_struct_tag {
    pthread_mutex_t mutex; /* Protects access to value */
    int value; /* Access protected by mutex */
} my_struct_t;

my_struct_t data = {PTHREAD_MUTEX_INITIALIZER, 0};

int main (int argc, char *argv[])
{
    return 0;
}

通常不能静态地初始化一个互斥量,例如当使用malloc动态分配一个包含互斥量的数据结构时。这时,应该使用pthread_mutex_init调用来动态地初始化互斥量,如下面程序mutex_dynamic.c所示。也可以动态地初始化静态声明的互斥量,但必须保证每个互斥量在使用前被初始化,而且只被始化一次。可以在创建任何线程之前初始化它,如通过调用pthread_once。如果需要初始化一个非缺省属性的互斥量,必须使用动态初始化。

#include <pthread.h>
#include "errors.h"
/*
 * Define a structure, with a mutex.
 */
typedef struct my_struct_tag {
    pthread_mutex_t mutex; /* Protects access to value */
    int value; /* Access protected by mutex */
} my_struct_t;

int main (int argc, char *argv[]) {
    my_struct_t *data;
    int status;
    data = malloc (sizeof (my_struct_t));
    if (data == NULL)
        errno_abort ("Allocate structure");
    status = pthread_mutex_init (&data->mutex, NULL);
    if (status != 0)
        err abort (status, "Init mutex");
    status = pthread_mutex_destroy (&data->mutex);
    if (status != 0)
        err_abort (status, "Destroy mutex");
    (void)free (data);
    return status;
}

将互斥量与它要保护的数据明显地联系起来是个不错的注意。如果可能的话,将互斥量和数据定义在一起。例如,在mutex_static.c和mutex_dynamic.c中,互斥量和它要保护的数据就被定义在同一个数据结构中,并通过注释语句记录了这种关系。

当不再需要一个通过pthread_mutex_init调用动态初始化的互斥量时,应该调用pthread_mutex_destroy来释放它。不需要释放一个使用
PTHREAD_MUTEX_INITIALIZER 宏静态初始化的互斥量。当确信没有线程在互斥量上阻塞时,可以立刻释放它。

当知道没有线程在互斥量上阻塞,且互斥量也没有被锁住时,可以安全地释放它。获知此信息的最好方式是在刚刚解锁互斥量的线程内,程序逻辑确保随后不再有线程会加锁该互斥量。当线程在某个堆栈数据结构中锁住互斥量,以从列表中删除该结构并释放内存时,在释放互斥量占有的空间之前先将互斥量解锁和释放是个安全且不错的主意。

2 加锁和解锁互斥量

int pthread_mutex_lock (pthread_mutex_t *mutex) 
int pthread_mutex_trylock (pthread_mutex_t *mutex)
int pthread_mutex_unlock (Pthread_mutex_t *mutex)

在最简单的情况下,使用互斥量是容易的事情。通过调用pthread_mutex_lock或pthread_mutex_trylock锁住互斥量,处理共享数据,然后调用pthread_mutex_unlock解锁互斥量。为确保线程能够读取一组变量的一致的值,需要在任何读写这些变量的代码段周围锁住互斥量。

当调用线程已经锁住互斥量之后,就不能再加锁该互斥量。试图这样做的结果可能是返回错误(EDEADLK),或者可能陷入”自死锁”,使不幸的线程永远等待下去。不能解锁一个已经解锁的互斥量,也不能解锁由其他线程锁住的互斥量。锁住的互斥量是属于加锁线程的。

下面程序alarm_mutex.c是alarm_thread.c的改进版本。它在一个alarmserver线程中处理多个闹铃的请求。

结构体alarm_t现在包含了一个标准UNIX time_t类型的绝对时间,表示从UNIX纪元(1970年1月1日00:00)开始到闹铃时的秒数。这样alarm_t结构体就可以按照闹铃时间排序,而不是按照请求的秒数排序。另外,还有一个link元素将所有请求链接起来。

互斥量alarm_mutex负责协调对闹铃请求列表alarm_list的头节点的访问。互斥量是使用默认属性调用宏 PTHREAD_MUTEX_INITIALIZER 静态初始化的。列首指针初始化为空。

#include <pthread.h>
#include <time.h>
#include "errors.h"
/*
 * The "alarm" structure now contains the tinme t (time since the
 * Epoch, in seconds) for each alarm, so thatthey can be
 * sorted. Storing the requested number of sedconds would not be
 * enough, since the "alarm thread" cannot tel11 how long it has
 * been on the list.
 */
typedef struct alarm_tag {
    struct alarm_tag *link;
    int seconds;
    time_t time; /* seconds from EPOCH */
    char message[64];
} alarm_t;

pthread_mutex_t alarm_mutex = PTHREAD_MUTEX_INITIALIZER;
alarm_t *alarm_list = NULL;

下面讲述函数alarm_thread的代码。该函数作为线程运行,并依次处理列表alarm_list中的每个闹铃请求。线程永不停止,当main函数返回时,线程”蒸发”。这种做法的惟一后果是任何剩余请求都不会被传送,线程没有保留任何能够在进程之外可见的状态。

如果希望程序在退出之前处理所有未完结的闹铃请求,可以简简单地修改程序以达到该目标。当主线程发现列表 alarm_list为空时,需要通过某种方式通知alarm_thread线程终止。例如,可以在主线程中设置一个全局变量alarm_done的值,然后调用pthread_exit而不是调用exit终止。当alarm_thread线程发现列表为空且alarm_done被置位时,它会立即调用pthread_exit,而不是等待下一个请求。

如果列表中没有新的请求,alarm_thread线程需要阻塞自己一小段时间,解锁互斥量,以便主线程能够添加新的闹铃请求。通过将sleep_time置为1秒来作到这点。

如果列表中发现请求,则将它从列表中删除。调用time函数放获得当前时间,并将其与请求时间比较。如果闹铃时间已经过期,则alarm_thread线程将sleep_time置为0;否则,alarm_thread线程计算闹铃时间与当前时间的差,并将sleep_time置为该差值(以秒为单位)。

在线程睡眠或阻塞之前,总是要解锁互斥量。如果互斥量仍被锁住,则主线程就无法向列表中增加请求。这将使程序变成同步工作方式,因为用户必须等到闹铃之后才能做其他事(用户可能输入一个命令,但是必须等到下一闹钟到期时才能获得系统提示)。调用sleep将阻塞alarm_thread线程指定的时间,直到经过该时间后线程才能运行。

调用sched_yield的效果果是将处理器交给另一个等待运行的线程,但是如果没有就绪的线程,则立即返回。在程序中,调用sched_yield意味着:如果有等待处理的用户输入,则主线程运行,处理用户请求;如果用户没有输入请求,则该函数立即返回。

如果alam指针非空,即如果已经从alarm_list列表中处理里了一个闹铃请求,则函数打印消息显示闹铃已到期。然后,线程释放alarm结构,准备处理下一个闹铃请求。

/*
 * The alarm thread's start routine.
 */
void *alarm_thread (void *arg) {
    alarm t *alarm;
    int sleep time;
    time t_now;
    int status;
    /*
    * Loop forever, processing commands. The alarmthread will
    * be disintegrated when the process exits.
    */
    while (1) {
        status = pthread_mutex_lock (&alarm_mutex);
        if (status != 0)
            err_abort (status, "Lock mutex");
        alarm = alarm_list;
        /*
         * If the alarm list is empty, wait for one second. This
         * allows the main thread to run, and readanother
         * command. If the list is not empty, remove tthe first
         * item. Compute the number of seconds to wait-- if the
         * result is less than 0 (the time has passed), t!hen set
         * the sleep time to 0.
         */
        if (alarm == NULL)
            sleep_time = 1;
        else {
            alarm_lişt = alarm->link;
            now = time (NULL);
            if (alarm->time <= now)
                sleep_time = 0;
            else
                sleep_time = alarm->time - now;
#ifdef DEBUG
            printf ("[waiting: %d(%d)\"Bs\"]\n", alarm->timne,
                sleep time, alarm->message);
#endif
        }

        /*
         * Unlock the mutex before waiting, so that thhe main
         * thread can lock it to insert a new alarm request.If
         * the sleep_time is 0, then call sched yield, giving9
         * /the main thread a chance to run if it has been n
         * readied by user input, without delaying the message
         * if there's no input.
         */
        status = pthread_mutex_unlock (salarm_mutex);
        if (status != 0)
            err_abort (status, "Unlock mutex");
        if (sleep_time > 0)
            sleep (sleep_time);
        else
            sched_yield ();
        /*
         * If a timer expired, print the message and free the
         * structure.
         */
        if (alarm != NULL) {
            printf("(td) ts\n", alarm->seconds, alarm->me(ssage)
            free (alarm);
        }
    }
}

最后,我们来讨论alarm_mutex.c的主程序代码。基本结构我们已经开发过的版本相同,包括一个循环、从stdin中读取用户输入的请求并依次处理它们。这一次,没有像alarm.c中那样同步地等待,也没有像alarm_fork.c 或alarm_thread.c 那样为每个请求创建一个异步处理实体进程或线程),而是将所有请求排队,等待服务线程alarm_thread处理。一旦主线程将所有请求排队,它就可以读取下一个请求了。

建立一个服务线程来处理所有请求。返回的线程ID保存在局部变量thread中(尽管我们不使用它)。

与其他的闹铃版本一样读取并处理用户请求。就像在alarm_thread.c中那样,数据保存在malloc分配的堆结构中。

程序将闹铃请求添加到alarm_list列表中,该列表由主线程和alarm_thread线程共享。所以在访问共享数据之前,需要将互斥量alarm_mutex加锁。

由于线程alarm_thread串行地处理列表中的请求,所以没有办法知道从读取用户请求到处理请求的时间间隔。因此,alarm结构中包含了闹铃的绝对时间。绝对时间是通过将用户输入的闹铃时间间隔加上由time调用返回的当前时间获得。

闹铃请求在列表alarm_list中按照闹铃时间先后顺序排序。插入代码遍历列表,直到找到第一个闹铃时间大于或等于新闹铃请求时间的节点,然后将新请求插入到找到的节点前。因为alarm_list是个简单的链表,遍历维护了两个指针:一个next指针指向当前节点,一个last指针指向前一个节点的link指针或者指向列表头指针。

如果没有找到大于或等于当前闹铃时间的节点,则将新请求节点插入列表尾部。当退出遍历时,如果当前节点指针为NULL,则前一节点(或链表头)指向新请求节点。

int main (int argc, char *argv[]) {
    int status;
    char line[128];
    alarm_t *alarm, **last, *next;
    pthread_t thread;

    status = pthread_create {
        &thread, NULL, alarm_thread, NULL);
    if (status != 0)
        err_abort (status, "Create alarm thread");
    while {1) {
        printf ("alarm> ");
        if(fgets(line, sizeof(line), stdin) == NULL) exit(0)
        if (strlen (line)<= 1) continue;
        alarm = (alarm_t*)malloc (sizeof (alarm_t));
        if (alarm == NULL)
            errno_abort ("Allocate alarm");
        /*
         * Parse input line into seconds (%d) and a message
         * (864[^\n]), consisting of up to 64 charadters
         * separated from the seconds by whitespace.
         */
        if (sscanf (line, "%d 864[^\n]",
            &alarm->seconds, alarm->message) < 2) {
            fprintf (stderr, "Bad command\n");
            free (alarm);
        } else {
            status = pthread_mutex_lock (&alarm_mutex);
            if (status != 0)
                err_abort (status, "Lock mutex");
            alarm->time = time (NULL) + alarm->seconds;
            /*
             * Insert the new alarm into the list of alarmis,
             * sorted by expiration time.
             */
            last = &alarm_list;
            next = *last;
            while (next!= NULL) {
                if (next->time >= alarm->time) {
                    alarm->link = next;
                    *last = alarm;
                    break;
                }
                last = &next->link;
                next = next->link;
            }
            /*
             * If we reached the end of the list, insert tthe new
             * alarm there. ("next" is NULL, and "last" pointts
             * to the link field of the last item, or tothe
             * list header).
             */
            if (next == NULL){
                *last = alarm;
                alarm->link = NULL;
            }
#ifdef DEBUG
            printf ("[list: ");
            for (next = alarm_list; next != NULL; next = 1next->link)
                printf ("#d{%d)[\'\'\'] ", next->time,
                    next->time - time (NULL), next->message);
            printf ("]\n");
#endif
            status = pthread_mutex_unlock (&alarm_mutex);
            if (status 1= 0)
                err_abort (status, "Unlock mutex");
        }
    }
}

这个简单的例子存在几个严重的缺点。尽管与alarm_fork.c 和 alarm_thread.c相比,该实例具有占用更少资源的优势,但它的响应性能不够。一旦alarm_thread线程从列表中接收了一个闹铃请求,它就进入睡眠直到闹铃到期。当它发现列表中没有闹铃请求时,也会睡眠1秒,以允许主线程接收新的用户请求。当alarm_thread线程睡眠时,它就不能注意到由主线程添加到请求列表中的任何闹铃请求,直到它从睡眠中返回。

这个问题可以通过不同的方式解决。当然最简单的为方式就是像alarm_thread.c那样为每个闹铃请求建立一个线程。当然这也不坏,因为线程还是比较廉价的。不过还是没有alarm_t数据结构廉价,并且我们更喜欢构造高效的程序,而不仅仅是响应性好的程序。最好的办法是使用条件变量来通知共享数据的状态变化。

3 非阻塞式互斥量锁

当调用pthread_mutex_lock加锁互斥量时,如果此时互斥量已经被锁住,则调用线程将被阻塞。通常这是你希望的结果,但有时你可能希望如果互斥量已被锁住,则执行另外的代码路线,你的程序可能做其他一些有益的工作而不仅仅是等待。为此,Pthreads提供了pthread_mutex_trylock函数,当调用互斥量已被锁住时调用该函数将返回错误代码EBUSY。

使用非阻塞式互斥量加锁函数时,需要确保只有当pthread_mutex_trylock函数调用成功时,才能解锁互斥量。只有拥有互斥量的线程才能解锁它。一个错误的pthread_mutex_trylock函数调用可能返回错误代码,或者可能解锁其他线程依赖的互斥量,这将导致程序产生难以调试的错误。

4 调整互斥量满足工作

互斥量有多大?我可不是指一个pthread_mutex_t类型的结构构占了多少内存。我是使用了一种通俗的、不完全准确的、但是可以被大多数人接受的说法。这种有趣的用法是在有关如何将非线程代码改为线程安全代码的过程中流行起来的。实现线程安全的函数库的一个相对简单的做法是创建一个互斥量,在每次进入函数库时锁住它,在退出库的时候解锁它,这样函数库就变成了一个串行区域,从而防止了线程问的任何冲突。我们就把保护如此大的串行区域的互斥量称为”大”互斥量,并形而上学地认为比那些只保护几行代码的互斥量要明显地大。

进一步扩展,保护两个变量的互斥量比保护一个变量的互斥量要”大”。那么到底互斥量该多大呢?答案只能是:足够大,但不要太大。

当需要保护两个共享变量时,你有两种基本策略:可以为每个个变量指派一个”小”的互斥量,或者为两个变量指派一个”大”的互斥量。哪一种方法更好取决于很多因素。并且,在开发过程中影响因素可能发生改变,这依赖于有多少线程需要共享数据和如何使用共享变量。

以下是主要的设计因素:

  • 互斥量不是免费的,需要时间来加锁和解锁。锁住较少互斥量的程序通常运行得更快。所以,互斥量应该尽量少,够用即可,每个互斥量保护的区域应则尽量大。
  • 互斥量的本质是串行执行。如果很多线程需要频繁地加锁同一个互斥量,则线程的大部分时间就会在等待,这对性能是有害的。如果互斥量保护的数据(或代码)包含彼此无关的片段,则可以将大的互斥量分解解为几个小的互斥量来提高性能。这样,任意时刻需要小互斥量的线程减少,线程等待时间就会减少。所以,互斥量应该足够多(到有意义的地步),每个互斥量保护的区域则应尽量的少。
  • 上述两方面看似互相矛盾,但是这不是我们头一次遇到的情清况。一旦当你解了互斥量的性能后,就能够正确地处理它们。

在复杂的程序中,通常需要一些经验来获得正确的平衡。在大多数情况下,如果你开始使用较大的互斥量,然后当经验或性能数据告诉你哪个地方存在频繁的竞争时,你应改用较小的互斥量,则你的代码通常会更为简单。简单单是好的。除非发现问题,否则不要轻易花费太多时间优化你的代码。

另一方面,如果从一开始就能明晰你的算法必然导致频繁的竞争,则不要过于简单化。开始使用必须的互斥量和数据结构比后来增加它们容易得多。

5 使用多个互斥量

有时,一个互斥量是不够的,特别是当你的代码需要跨越软件体系内部的界限时。例如,当多个线程同时访问一个队列结构时,你需要两个互斥量,一个用来保护队列头,一个用来保护队列元素内的数据。当为多线程建立一个树型结构时,你可能需要为每个节点设置一个互斥量。

同时使用多个互斥量会导致复杂度的增加。最坏的情况就是死锁的发生,即两个线程分别锁住一个互斥量而等待对方的互斥量。

如果可以在独立的数据上使用两个分离的互斥量,那么就应该这样做。这样,通过减少线程必须等待其他线程完成数据操作(甚至是该线程不需要的数据)的时间,你的程序会最终取得成功。如果数据独立,则某个特定函数就不太可能经常需要同时加锁两个互斥量。

当数据不是完全独立的时候,情况就复杂了。如果你的程序中有一个不变量,影响着由两个互斥量保护的数据,即使该不变量很少被改变或引用,你迟早需要编写同时锁住两个互斥量的代码,来确保不变量的完整。如果一个线程锁住互斥量A后,加锁互斥量B;同时另一个线程锁住互斥量B而等待互斥量A,则你的代码就产生了一个经典的死锁现象。

重载函数调用运算符

C++允许重载函数调用运算符,写作operator()。如果在自定义的类中编写一个operator(),那么这个类的对象就可以当成函数指针使用。包含函数调用运算符的类对象成为函数对象,或简称为仿函数。只能将这个运算符重载为类的非静态方法。

下面是一个简单的类,它带有一个重载的operator()以及一个具有相同行为的类方法:

class FuncO {
    public:
    FuncO operator () (int param); // Function call operator
    int doSquare (int param);
};

// Impelementation of overloaded function call operator
FuncO FuncO::operator () (int param) {
    cout << doSquare (param) << endl;
    return FuncO (*this);
}

// Implementation of nomal method
int FuncO::doSquare (int param) {
    return param * param;
}

int main ()
{
    FuncO square;
    square (2) (3) (4);
    cout << square.doSquare(5) <<endl;
}

这个代码示例里重载的函数调用运算符比较有趣,它的返回类型是一个FuncO对象,这个对象也是函数对象,也可以直接当成函数指针来使用,所以我们看到下面在出现了square (2) (3) (4),连续调用了3次,第一次square (2) 返回了一个函数对象,接着这个函数对象调用square (2)(3),然后又返回一个函数对象,接着调用square (2) (3) (4)。

运行结果如下:

$ ./FuncO 
4
9
16
25

这个例子在VTM代码中的参数解析中有用到。

C++ 中使用 argc 和 argv 的命令行参数

命令行参数在 DOS 或 Linux 等命令行操作系统中的程序名称之后给出,并从操作系统传递给程序。要在程序中使用命令行参数,必须首先了解 main 函数的完整声明,main 函数实际上可以接受两个参数:一个参数是命令行参数的数量,另一个参数是所有命令行参数的完整列表。

main 的完整声明如下所示:

int main ( int argc, char *argv[] )
  • int型 argc 是 ARGument Count(因此为 argc)。它是从命令行传递给程序的参数数量,包括程序的名称。
  • argv数组是所有参数的列表。argv[0] 是程序的名称,如果名称不可用,则为空字符串。每个小于 argc 的元素编号都是命令行参数。可以像使用字符串一样使用每个 argv 元素,也可以将 argv 用作二维数组。argv[argc] 是一个空指针。
  • 参数由空格分隔,也可以是制表符。

下面给出了C++的实例,可以将所有命令行参数打印出来。

#include <iostream>
using namespace std;
 
int main ( int argc, char *argv[] )
{
    for (int i = 0; i < argc; i++) {
        cout << "argv[" << i << ']' << ": " << argv[i] << endl;
    }
    return 0;
}

运行结果如下:

$ ./arg -a 123 -bcd 4\"5 ef ghj
argv[0]: ./arg
argv[1]: -a
argv[2]: 123
argv[3]: -bcd
argv[4]: 4"5
argv[5]: ef
argv[6]: ghj

多媒体指令集SIMD优化入门

以下内容翻译自:
Practical SIMD Programing–Jacco Bikker 2017
Basics of SIMD Programming

SIMD 操作能够用一条指令处理多个数据,广泛用于多媒体应用中的 3D 图形和音频/视频处理。SIMD全称Single Instruction Multiple Data,单指令多数据流,能够复制多个操作数,并把它们打包在大型寄存器的一组指令集。一条指令操作多个数据.是CPU基本指令集的扩展,也就是说一次运算指令可以执行多个数据流,这样在很多时候可以提高程序的运算速度。

1 SIMD Concepts

SIMD 是 Single Instruction Multiple Data 的缩写,而 SIMD 操作一词是指一种计算方法,可以用一条指令处理多个数据。相比之下,使用一条指令来处理每个单独数据的传统顺序方法称为标量操作。

以一个简单的求和为例,标量和 SIMD 操作之间的区别如下所示。

对于传统的标量运算,必须依次执行四个加法指令才能获得如图  (a) 所示的和。同时,SIMD 仅使用一条加法指令即可达到相同的结果,如图 (b) 所示。SIMD 操作需要更少的指令来处理给定的大量数据,其效率高于标量操作。

SIMD 操作不能用于以不同方式处理多个数据。图 2.3 给出了一个典型的例子,其中一些数据要相加,而另一些数据要减去、相乘或相除。

CPU 使用寄存器来存储要操作的数据。典型的寄存器存储 32 或 64 位,并保存单个标量值。CPU 指令通常对两个操作数进行操作。考虑以下代码片段:

vec3 velocity = GetPlayerSpeed();
float length = velocity.Length();

计算该向量长度需要大量的标量操作:

x2 = velocity.x * velocity.x
y2 = velocity.y * velocity.y
z2 = velocity.z * velocity.z
sum = x2 + y2
sum = sum + z2
length = sqrtf( sum )

矢量寄存器存储 4 个 (SSE) 或 8 个 (AVX) 标量。这意味着 C++ 向量在汇编程序级别仍然是一个向量:我们不是将三个单独的值存储在三个寄存器中,而是将四个值(x、y、z 和一个虚拟值)存储在一个向量寄存器中。而且,我们不是分别对 x、y 和 z 进行平方,而是使用单个 SIMD 指令对三个值(以及虚拟值)进行平方。

这个简单示例说明了我们在编写 SIMD 代码时需要处理的一些问题:

  • 在对三分量向量进行操作时,我们没有使用向量处理器的全部计算潜力:我们浪费了 SIMD 寄存器中 25%(对于 SSE)或 62.5%(对于 AVX)的“槽”。
  • 在向量寄存器中存储三个标量不是免费的:成本取决于我们稍后将讨论的许多因素。这给计算增加了一些开销。
  • 最后一行的平方根仍然对单个值执行。因此,尽管这是最昂贵的线路,但它并没有从矢量硬件中受益,从而限制了我们的收益。

有一种可靠的方法可以减轻这些担忧。假设我们的应用程序实际上是一个四人游戏:

for( int i = 0; i < 4; i++ )
{
   vec3 velocity = GetPlayerSpeed();
   float length = velocity.Length();
}

在这种情况下,我们可以同时对四个向量进行操作:

x4 = GetPlayerXSpeeds();
y4 = GetPlayerYSpeeds();
z4 = GetPlayerZSpeeds();
x4squared = x4 * x4;
y4squared = y4 * y4;
z4squared = z4 * z4;
sum4 = x4squared + y4squared;
sum4 = sum4 + z4squared;
length4 = sqrtf4( sum4 );

请注意,我们已将 C++向量概念与 SIMD 向量完全解耦:我们只需使用 SIMD 向量并行执行原始标量功能四次。现在每一行都使用一条 SIMD 指令,效率为 100%(当然,我们需要 8 名玩家来进行 AVX ……),甚至现在计算平方根也是为了四个数字。

这里需要注意一件重要的事情:为了使前三行有效,玩家速度必须已经以“SIMD-friendly”格式存储,即:xxxx、yyyy、zzzz。像这样组织的数据可以直接复制到向量寄存器中。

这也意味着我们不可能期望编译器自动为我们执行此操作。高效的 SIMD 代码需要高效的数据布局;这必须手动完成。

2 Data Parallelism

具有四个玩家速度的示例将浪费 AVX 机器上 50% 的计算潜力。显然,我们需要更多的工作。高效的 SIMD 代码需要大量数据并行性,其中针对大量输入执行一系列操作。达到 100% 的效率要求输入数组大小是 4 或 8 的倍数;然而,对于任何重要的输入数组大小,我们都非常接近这个最佳值,并且 AVX 性能只是 SSE 性能的两倍。

对于数据并行算法,SIMD 寄存器中的每个标量都保存一个“线程”的数据。我们调用寄存器通道中的插槽。输入数据称为流。

如果您是 C++ 程序员,您可能熟悉基本类型:char、short、int、float 等。它们中的每一个都有特定的大小:char 为 8 位,short 为 16 位,int 和 float 为 32 位。位只是位,因此 float 和 int 之间的区别在于解释。这允许我们做一些讨厌的事情:

int a;
float& b = (float&)a;

这将创建一个整数和一个指向 a 的浮点引用。由于变量 a 和 b 现在占用相同的内存位置,因此更改 a 会更改 b,反之亦然。实现此目的的另一种方法是使用union:

union { int a; float b; };

同样,a 和 b 驻留在同一内存位置。这是另一个例子:

union { unsigned int a4; unsigned char a[4]; };

这一次,一个由四个字符组成的小数组与 32 位整数值 a4 重叠。我们现在可以通过数组 a[4] 访问 a4 中的各个字节。请注意,a4 现在基本上有四个 1 字节的“通道”,这有点类似于我们使用 SIMD 得到的。我们甚至可以将 a4 用作 32 个 1 位值,即存储 32 个布尔值。

SSE 寄存器大小为 128 位,如果用于存储四个浮点数,则命名为 __m128,对于整数,则命名为 __m128i。为方便起见,我们将 __m128 发音为“quadfloat”,将 __m128i 发音为“quadint”。AVX 版本是 __m256(’octfloat’)和 __m256i(’octint’)。为了能够使用 SIMD 类型,我们需要包含一些头文件:

#include "nmmintrin.h" // for SSE4.2
#include "immintrin.h" // for AVX

一个 __m128 变量包含四个浮点数,所以我们可以再次使用union:

union { __m128 a4; float a[4]; };

现在我们可以方便地访问 __m128 向量中的各个浮点数。

我们也可以直接创建 quadfloat:

__m128 a4 = _mm_set_ps( 4.0f, 4.1f, 4.2f, 4.3f );
__m128 b4 = _mm_set_ps( 1.0f, 1.0f, 1.0f, 1.0f );

要将它们加在一起,我们使用 _mm_add_ps:

__m128 sum4 = _mm_add_ps( a4, b4 );

__mm_set_ps 和 _mm_add_ps 关键字为内置函数。SSE 和 AVX 内置函数都编译为一条汇编指令;使用这些意味着我们实际上是直接在我们的程序中编写汇编代码。几乎每个标量操作都有一个内置函数:

_mm_sub_ps( a4, b4 );
_mm_mul_ps( a4, b4 );
_mm_div_ps( a4, b4 );
_mm_sqrt_ps( a4 );
_mm_rcp_ps( a4 ); // reciprocal

对于 AVX,我们使用类似的内在函数:只需在前面加上 _mm256 而不是 _mm,因此:_mm256_add_ps(a4, b4),等等。

SSE 和 AVX 指令的完整概述可以在这里找到:

https://software.intel.com/sites/landingpage/IntrinsicsGuide/

您可以放心地假设 2000 年之后生产的任何 CPU 都支持最高 4.2 的 SSE。AVX,尤其是 AVX2 是较新的技术;查看 Wikipedia 以获取支持处理器的列表:

https://en.wikipedia.org/wiki/Advanced_Vector_Extensions

3 A Practical Example: C++

以下代码呈现了一个 Mandelbrot 分形:

float scale = 1 + cosf( t );
t += 0.01f;
for( int y = 0; y < SCRHEIGHT; y++ )
{
   float yoffs = ((float)y / SCRHEIGHT - 0.5f) * scale;
   float xoffs = -0.5f * scale, dx = scale / SCRWIDTH;
   for( int x = 0; x < SCRWIDTH; x++, xoffs += dx )
   {
      float ox = 0, oy = 0, py;
      for( int i = 0; i < 99; i++ ) px = ox, py = oy,
         oy = -(py * py - px * px - 0.55f + xoffs),
         ox = -(px * py + py * px - 0.55f + yoffs);
      int r = min( 255, max( 0, (int)(ox * 255) ) );
      int g = min( 255, max( 0, (int)(oy * 255) ) );
      screen->Plot( x, y, (r << 16) + (g << 8) );
} }

请注意,此代码经过了很好的优化,并且计算量很大。我们可以很容易地在多核上运行这段代码:像素之间没有依赖关系,所以这个算法是令人尴尬的并行。但为了获得最佳性能,我们还需要使用指令级并行性。这意味着每个标量操作都应该针对四个输入元素执行。繁重的工作发生在内部循环中,所以如果我们只是优化它,我们应该会看到一个不错的加速。让我们考虑一下我们的选择:内部循环中有循环依赖,所以我们不能并行运行迭代。然而,我们可以并行处理四个像素。

我们现在将逐步将现有的标量代码转换为矢量化代码。我将使用 SSE,但稍作修改后,相同的过程也适用于 AVX。

Step 1:备份原代码

最好的方法是使用 #if 1 … #else … #endif 块。这样原始代码触手可及,万一出现问题,或者仅供参考。

Step 2:创建四个流

我们首先模拟四个流的使用。一次处理四个像素意味着 x 以 4 为步长增加。除此之外,我们需要 ox 和 oy 变量的四个副本,因为现在将针对四个像素并行计算这些副本。

for( int x = 0; x < SCRWIDTH; x += 4, xoffs += dx * 4 )
{
  float ox[4] = { 0, 0, 0, 0 }, oy[4] = { 0, 0, 0, 0 }; 
  for( int lane = 0; lane < 4; lane++ )

内部循环的内容几乎没有改变:我们做同样的工作,但是我们现在对数组元素进行操作,而不是对 ox 和 oy 进行操作:

for( int i = 0; i < 99; i++ ) px = ox[lane], py = oy[lane],
    oy[lane] = -(py * py - px * px - 0.55f + xoffs + lane * dx),
    ox[lane] = -(px * py + py * px - 0.55f + yoffs);

最后,我们需要绘制四个像素。让我们在一个单独的循环中执行此操作,因此我们不能将该循环转换为 SIMD,或者单独进行转换:

for( int lane = 0; lane < 4; lane++ )
{
    int r = min( 255, max( 0, (int)(ox[lane] * 255) ) );
    int g = min( 255, max( 0, (int)(oy[lane] * 255) ) );
    screen->Plot( x + lane, y, (r << 16) + (g << 8) );
}

Step 3:创建 SIMD 数据结构

这是一个简单的步骤:我们已经在 ox[4] 和 oy[4] 中有四个通道的数据,这意味着我们有两组四个浮点数,这正是存储在 quadfloat 中的内容。

union { __m128 ox4; float ox[4]; };
union { __m128 oy4; float oy[4]; };
ox4 = oy4 = _mm_setzero_ps();

最后一行使用内部函数将 128 位向量设置为零。

Step 4:检查功能

我们正在对我们的代码进行一些相当侵入性的更改,因此请定期确保一切仍按预期工作!

Step 5:转换内循环

由于已经准备好流转换,所以最终的转换很简单:

for( int i = 0; i < 99; i++ ) px4 = ox4, py4 = oy4,
    oy4 = -(py4 * py4 – px4 * px4 - 0.55f + xoffs4),
    ox4 = -(px4 * py4 + py4 * px4 - 0.55f + yoffs4);

这段代码不起作用,但它确实让我们清楚地知道我们想去哪里。流上的循环消失了,因为我们现在并行执行这些。ox[lane] 和 oy[lane] 的使用被 ox4 和 oy4 取代。变量 px4 和 py4 现在也应该是 quadfloats。一些问题仍然存在:

  • 一个不是简单地使用 * 运算符将两个四元浮点数相乘; 
  • xoffs4 的内容有点复杂。

关于 xoffs4:变量 xoffs 过去每次迭代都会增加 dx。所以,我们正在寻找的是一个由四个浮点数组成的数组,包含 { xoffs, xoffs + dx, xoffs + 2 * dx, xoffs + 3 * dx }:

__m128 xoffs4 = _mm_set_ps( xoffs, xoffs + dx, xoffs + dx * 2, xoffs + dx * 3 );

变量 yoffs4 对四个像素中的每一个都包含相同的值:

__m128 yoffs4 = _mm_set_ps( yoffs, yoffs, yoffs, yoffs );

剩下的就是操作者了。我们需要用 _mm_mul_ps 替换每个乘法,用 _mm_sub_ps 替换每个减法,等等。让我们为 oy4 执行此操作:

oy4 = -(py4 * py4 - px4 * px4 - 0.55f + xoffs4);

变成

oy4 =
_mm_sub_ps(
    _mm_setzero_ps(),
    _mm_add_ps(
       _mm_sub_ps(
          _mm_sub_ps(
             _mm_mul_ps( py4, py4 ),
             _mm_mul_ps( px4, px4 )
          ),
          _mm_set1_ps( 0.55f )
    ),
xoffs4 ) );

把所有东西放在一起,我们得到了最终的矢量化程序:

for( int y = 0; y < SCRHEIGHT; y++ )
{
    float yoffs = ((float)y / SCRHEIGHT - 0.5f) * scale; float xoffs = -0.5f * scale, dx = scale / SCRWIDTH; for( int x = 0; x < SCRWIDTH; x += 4, xoffs += dx * 4 ) {
    union { __m128 ox4; float ox[4]; };
    union { __m128 oy4; float oy[4]; };
    ox4 = oy4 = _mm_setzero_ps();
    __m128 xoffs4 = _mm_setr_ps( xoffs, xoffs + dx,
                    xoffs + dx * 2, xoffs + dx * 3 );
    __m128 yoffs4 = _mm_set_ps1( yoffs );
    for( int i = 0; i < 99; i++ )
    {
        __m128 px4 = ox4, py4 = oy4;
        oy4 = _mm_sub_ps( _mm_setzero_ps(), _mm_add_ps( _mm_sub_ps(
              _mm_sub_ps( _mm_mul_ps( py4, py4 ), _mm_mul_ps( px4, px4 ) ),
              _mm_set_ps1( 0.55f ) ), xoffs4 ) );
        ox4 = _mm_sub_ps( _mm_setzero_ps(), _mm_add_ps( _mm_sub_ps(
              _mm_add_ps( _mm_mul_ps( px4, py4 ), _mm_mul_ps( py4, px4 ) ),
              _mm_set_ps1( 0.55f ) ), yoffs4 ) );
    }
    for( int lane = 0; lane < 4; lane++ )
    {
        int r = min( 255, max( 0, (int)(ox[lane] * 255) ) );
        int g = min( 255, max( 0, (int)(oy[lane] * 255) ) );
        screen->Plot( x + lane, y, (r << 16) + (g << 8) );
    } 
}

正如所承诺的那样,此代码的运行速度几乎是原始代码的四倍。

4 Conditional Code & SIMD

代码向量化是将现有代码转换为可以并行执行的独立标量流的过程,其中每个任务执行相同的指令。这样,可以使用“单指令多数据”指令同时执行四个或八个(或更多)标量流。

到目前为止,我们矢量化的代码相对简单:图像的所有像素都可以独立计算,以任意顺序计算,也可以并行计算,对于每个像素,我们执行完全相同的指令。但是,如果事情没有那么简单呢?最常见的复杂情况是条件代码:任何涉及 if 语句、条件表达式(例如 a=b>a?a:b),但也包括具有可变迭代次数的循环、switch 语句等。显然,任何有条件的东西都可能导致标量流不执行相同的代码。

考虑我们矢量化 Mandelbrot 示例中的第二个循环:

for( int lane = 0; lane < 4; lane++ )
{
    int r = min( 255, max( 0, (int)(ox[lane] * 255) ) );
    int g = min( 255, max( 0, (int)(oy[lane] * 255) ) );
    screen->Plot( x + lane, y, (r << 16) + (g << 8) );
}

这里使用的 min 和 max 函数隐藏了一些条件代码。Min 可以实现为:

int min( a, b ) { if (a < b) return a; else return b; }

或者使用条件表达式:

#define min(a,b) ((a)<(b)?(a):(b));

对于最小值和最大值的特定情况,SSE 和 AVX 提供了一个有效的解决方案:

__m128 c4 = _mm_min_ps( a4, b4 );
__m128 c4 = _mm_max_ps( a4, b4 );

这些指令的存在有时会导致 SSE 代码超过预期的最佳 400% 效率:条件代码会导致 CPU 延迟,但在 SSE 和 AVX 中,min 和 max 根本不是条件的。

我们现在可以矢量化部分像素绘图循环:

__m128 C4 = _mm_set_ps1( 255.0f );
ox4 = _mm_min_ps( C4, _mm_max_ps( _mm_setzero_ps(), _mm_mul_ps( ox4, C4 ) ) );
oy4 = _mm_min_ps( C4, _mm_max_ps( _mm_setzero_ps(), _mm_mul_ps( oy4, C4 ) ) );
for( int lane = 0; lane < 4; lane++ )
{
    int r = (int)ox[lane];
    int g = (int)oy[lane];
    screen->Plot( x + lane, y, (r << 16) + (g << 8) );
}

请注意,常量 255.0f 存储在一个变量中,因此我们不必执行 _mm_set1_ps 指令四次,而只需执行一次。

事实上,我们可以更进一步:从 float 到 int 的转换也可以使用 SSE 指令完成

union { __m128i tmp1; int oxi[4]; }; tmp1 = _mm_cvtps_epi32( ox4 );
union { __m128i tmp2; int oyi[4]; }; tmp2 = _mm_cvtps_epi32( oy4 );

请注意,union现在组合了一个四元组和一个整数数组。

现在在第二个循环中只剩下一条线,用于绘制像素。plot是surface类的一个方法,实现如下:

void Surface::Plot( int x, int y, Pixel c )
{
    if ((x >= 0) && (y >= 0) && (x < m_Width) && (y < m_Height))
        m_Buffer[x + y * m_Pitch] = c;
}

这里,“Pixel”只是一个 32 位无符号整数,m_Width 和 m_Height 是表面的宽度和高度。if 语句防止像素被绘制到屏幕外。在 Mandelbrot 应用程序中,这永远不会发生,但显然其他应用程序可能需要此功能。

Surface::Plot 的 SSE 版本可能如下所示:

void Surface::Plot4( __m128i x4, __m128i y4, __m128i c4 )
{
  if ((x4 >= 0) && (y4 >= 0) && (x4 < m_Width) && (y4 < m_Height))
        ...
}

这次我们遇到了一个问题。SSE和AVX没有与if语句等效的指令,这是有充分理由的:我们在标量代码中看到的布尔表达式将成为“quadbool”表达式,而条件代码(将某些内容存储在像素缓冲区中)可能必须对任何、部分或所有通道执行。

我刚刚写的SSE和AVX没有if语句,但它们实际上有比较指令。它们不会产生“四布尔”,但会返回一些有用的东西:位掩码。以下是一个例子:

__m128 mask = _mm_cmpge_ps( x4, _mm_setzero_ps() ); // if (x4 >= 0)

此行采用 x4 和一个包含零的 quadfloat,并检查第一个操作数是否大于或等于第二个操作数。对于大于 (_mm_cmpgt_ps)、小于 (_mm_cmplt_ps)、小于或等于 (_mm_cmple_ps)、等于 (_mm_cmpeq_ps) 和不等于 (_mm_cmpne_ps) 存在类似的比较指令。

掩码值为 128 位值。比较后,其内容反映了结果:“假”为 32 个零,“真”为 32 个零。

我们还可以结合比较:

__m128 mask1 = _mm_cmpge_ps( x4, _mm_setzero_ps() ); // if (x4 >= 0)
__m128 mask2 = _mm_cmpge_ps( y4, _mm_setzero_ps() ); // if (y4 >= 0)
__m128 mask = _mm_and_ps( mask1, mask2 ); // if (x4 >= 0 && y4 >= 0)

这些实际上都不是有条件的:我们无条件地计算位掩码。生成的位掩码可以两种不同的方式使用。第一种方法是中断向量指令流,并切换到标量代码来处理比较结果。为此,我们使用 _mm_movemask_ps 指令。该指令采用掩码,并返回一个 4 位值,如果通道的 32 位为 1,则每个位设置为 1,否则设置为 0。现在我们可以单独测试这些位:

int  result = _mm_movemask_ps( mask );
if (result & 1) { ... } // result for first lane is true
if (result & 2) { ... } // result for second lane is true
if (result & 4) { ... } // result for third lane is true
if (result & 8) { ... } // result for fourth lane is true

好处是我们现在至少使用矢量代码进行了比较。但是我们并没有解决实际问题:条件代码仍然破坏了我们的向量流。

为了解决这个问题,我们需要以不同的方式使用掩码:禁用通道的功能。考虑实际的条件代码:

m_Buffer[x + y * m_Pitch] = c;

这一行将一个无符号整数写入屏幕缓冲区中的地址。现在,如果我们将该地址替换为其他安全位置,例如虚拟变量的地址,该怎么办?我们仍然会执行写入,但这次它不会产生可见像素。

让我们考虑一个更实用的解决方案:如果一个像素恰好不在屏幕上,我们将其写入位置 (0,0)。当然,这个像素会包含废话,因为它会被所有屏幕外像素覆盖,但为了这个例子,我们认为这是可以接受的。为了实现这一点,我们将像素地址计算 x + y * m_Pitch 替换为 (x + y * m_Pitch) * 0。无论 x、y 和 m_Pitch 的值是什么,这个等式的结果都是 0。而这种操作正是这些掩码设计的目的。

让我们计算绘图语句的完整掩码:

__m128 mask1 = _mm_cmpge_ps( x4, _mm_setzero_ps() );
__m128 mask2 = _mm_cmpge_ps( y4, _mm_setzero_ps() );
__m128 mask3 = _mm_cmplt_ps( x4, _mm_set_ps1( m_Width ) );
__m128 mask4 = _mm_cmplt_ps( y4, _mm_set_ps1( m_Height ) );
__m128 mask = _mm_and_ps( _mm_and_ps( _mm_and_ps( mask1, mask2 ), mask3 ), mask4 );

我们可以如下计算四个像素地址:

__m128i address4 = _mm_add_epi32( _mm_mullo_epi32( y4, m_Pitch4 ), x4 ); 
address4 = _mm_and_si128( address, *(__m128i*)&mask ) );

关于这些行的几点说明:

  • 两个 32 位整数相乘产生一个 64 位整数,它不适合 32 位通道。_mm_mullo_epi32 指令丢弃前 32 位,在这种情况下很好。
  • 没有_mm_and_epi32指令;而是使用 _mm_and_si128 直接对 128 位进行按位和整数运算。
  • 我们的掩码是一个 quadfloat,而 _mm_and_si128 需要一个 quadint 掩码。因此,我们将其即时转换为正确的类型。
  • 第二行使用计算的掩码将所有屏幕外像素地址重置为 0,正如我们计划的那样。

现在还有一件事要做:将四个像素绘制到存储在 quadint address4 中的地址。我们想要进行的写入被称为分散:四个地址可能彼此相邻,但也可能遍布屏幕。没有支持此功能的 SSE 和 AVX 指令,因此我们唯一的选择是使用四个 32 位写入来执行此操作。尽管这破坏了我们的向量流,但这些都不是有条件的。

最终的 Plot4 方法:

void Surface::Plot4( __m128 x4, __m128 y4, __m128i c4 )
{
    __m128 mask1 = _mm_cmpge_ps( x4, _mm_setzero_ps() );
    __m128 mask2 = _mm_cmpge_ps( y4, _mm_setzero_ps() );
    __m128 mask3 = _mm_cmplt_ps( x4, _mm_set_ps1( (float)m_Width ) );
    __m128 mask4 = _mm_cmplt_ps( y4, _mm_set_ps1( (float)m_Height ) );
    __m128 mask = _mm_and_ps( _mm_and_ps( _mm_and_ps( mask1, mask2 ), mask3 ), mask4 ); union { __m128i address4; int address[4]; };
    __m128i m_Pitch4 = _mm_set1_epi32( m_Pitch );
    __m128i x4i = _mm_cvtps_epi32( x4 );
    __m128i y4i = _mm_cvtps_epi32( y4 );
    address4 = _mm_add_epi32( _mm_mullo_epi32( y4i, m_Pitch4 ), x4i );
    for( int i = 0; i < 4; i++ ) 
        m_Buffer[address[i]] = c4.m128i_i32[i];
}

请注意,该函数现在对 x4 和 y4 采用 quadfloats;这是因为 quadints 的 SSE 指令集比 quadfloats 更受限制。特别是缺少 _mm_cmpge_epi32。可以模拟此功能,但这会使代码不太清晰。

5 Fun with Mask

在上一节中,我们使用 128 位掩码来取消计算。我们通过使用 _mm_and_sil128 使用整数“and”来做到这一点。我们将它应用于包含地址的 quadint 变量(实际上是:从屏幕缓冲区开始的偏移量),但同样的技巧适用于浮点数。为此,我们“abuse”了浮点数 0.0f 的一个有趣属性:它的二进制表示是 32 个零。这意味着如果我们“和”一个具有 32 个零的浮点数,我们将重置其所有位,从而使浮点值变为 0.0f。‘And’ing 与 32 个 1 无关:我们只保留原始浮点数。一个例子:

__m128 mask = ...; // some comparison
a4 = _mm_and_ps( a4, mask );

如果掩码中的相应通道为“false”,则第二行将 quadfloat a4 的通道设置为 0.0f。

根据条件,我们可能想在某些通道上放置零以外的东西。考虑以下条件表达式:

float a = b == 0 ? b : c;

…如果其值为零,则将 a 替换为 b,否则将其替换为 c。一种方法是再次使用掩码:

__m128 mask = _mm_cmpeq_ps( a4, _mm_setzero_ps() );
__m128 part1 = _mm_and_ps( mask, b4 );
__m128 part2 = _mm_andnot_ps( mask, c4 );
a4 = _mm_or_ps( part1, part2 );

在这里,part1 将包含掩码为false的每个通道的零,以及掩码为true的 b4 中的值。Quadfloat part2 使用反转掩码,并从 c4 中选择。请注意,part1 和 part2 没有重叠:如果一个通道在 part1 中不为零,那么它在 part2 中将为零,反之亦然。因此,这两个部分可以安全地混合以获得最终结果。

获得此结果的更直接方法是使用 _mm_blendv_ps 指令:

__m128 mask = _mm_cmpeq_ps( a4, _mm_setzero_ps() );
a4 = _mm_blendv_ps( b4, c4, mask );

_mm_blendv_ps 内在函数根据掩码从 b4 和 c4 中选择值:如果掩码中的值设置为 true,则将选择 c4 中的值,否则将选择 b4 中的值。

6 Optimizating and Debugging SIMD Code

在前面的部分中,我们已经了解了如何对代码进行矢量化,以及如何处理条件代码。在本节中,我们将讨论一些提高 SIMD 代码效率的常见机会。

指令计数:原则上,每个内在函数都编译为单个编译器指令。这意味着更短的源代码会产生更小的程序,大多数情况下运行速度会更快。有时,诸如 _mm_blendv_ps 之类的高级指令可以替代一系列更简单的指令。因此,熟悉可用的说明会很有帮助。

浮点与整数: SSE 和 AVX 中的浮点支持比整数支持要好得多。有时临时转换为浮点数可以使您的代码更高效,即使这意味着您需要稍后再转换回来。浮点运算肯定会让您的生活更轻松:许多整数内在函数非常晦涩(参见例如_mm_mullo_epi32)。

减少 _mm_set_ps 的使用: 在向量化代码中经常需要常量,正如我们在 Mandelbrot 示例中看到的那样。在现场为这些创建quadfloat可能很诱人。但是,_mm_set_ps 是一个昂贵的函数,因为它需要四个操作数。考虑缓存结果:计算循环外的 quadfloat,这样您就可以在循环内多次使用它而不会受到惩罚。同样,如果您需要将标量扩展为 quadfloats(如 Plot 方法中的 m_Pitch),请考虑在类中缓存扩展版本。

避免收集操作:与 _mm_set_ps 相关的另一个陷阱是您提供给它的数据来自分散在内存中的位置。从内存中获取数据到 quadfloat 的最快方法是当它已经作为 quadfloat 存储在内存中时,即 16 个连续字节。

数据对齐:要记住的一件事是,内存中的 quadfloat 必须始终存储在 16 的倍数的地址中。否则将导致崩溃。这就是 C# 对 SSE/AVX 数据使用慢速未对齐读取的原因:C# 不能保证数据对齐。在 C++ 中,在堆栈上创建的变量将自动遵守此规则。然而,使用 new 分配的变量可能未对齐,从而导致意外崩溃。如果您确实遇到了崩溃,请检查正在处理的数据是否正确对齐:(十六进制)地址应始终以零结尾。

C++ 调试器:对 SIMD 的支持很好地集成在 Visual Studio 调试器中。你可以例如轻松检查 SIMD 变量中的各个值。

AVX/AVX2 支持: 如果您的处理器恰好是 AMD 和 Intel 必须提供的最新最好的处理器,请注意您生成的某些代码可能无法在您邻居的笔记本电脑上运行。在 C++ 中,完全有可能生成一个无法运行的 .exe,例如AVX2 不可用。确保牢记目标硬件,或为旧硬件提供替代实现。这个问题的一个例子:Metal Gear V 的早期破解需要一些模糊的 SSE 指令,这些指令在某些 AMD 硬件上不可用,即使该硬件完全能够运行游戏本身。

仅向量化瓶颈:在 Mandelbrot 示例中,我们对 Plot 方法进行了向量化,尽管它只消耗了一小部分时间。不要在现实世界中这样做:矢量化很难,您只想将精力集中在瓶颈上。在 Mandelbrot 示例中,更新 ox 和 oy 的大规模循环是一个很好的示例:大量工作集中在一小部分代码中,急需进行接近金属的优化。

避开花哨的 SIMD 库:矢量化很难,当你打算写 a * b 时,写 _mm_mul_ps(a,b) 感觉很不自然。抵制编写自己的运算符的冲动;习惯原始的内在函数。任何更复杂的东西都必然会隐藏效率低下,甚至引入它们。

优化代码内存访问

以下内容总结自《Intel® 64 and IA-32 Architectures Optimization Reference Manual》

本文内容讨论针对Intel处理器优化代码内存访问的相关技术。主要内容如下:

1 加载和存储执行带宽

通常,加载和存储是代码执行中最频繁的操作,高达 40% 的加载和存储指令并不少见。每一代微架构都提供了多个缓冲区来支持在有指令运行时执行加载和存储操作。这些缓冲区由 Sandy Bridge 和 Ivy Bridge 微架构的 128 位组成。 在 Haswell、Broadwell 和 Skylake Client 微架构中,大小增加到 256 位; 以及 Skylake Server、Cascade Lake、Cascade Lake Advanced Performance 和 Ice Lake 客户端微架构中的 512 位。 为了最大限度地提高性能,最好使用平台中可用的最大宽度。

1.1 在 Sandy Bridge 微架构中利用加载带宽

虽然先前的微架构只有一个加载端口(端口 2),但 Sandy Bridge 微架构可以从端口 2 和端口 3 加载。因此,每个周期可以执行两次加载操作,并使代码的加载吞吐量翻倍。 这改进了读取大量数据并且不需要经常将结果写入内存的代码(端口 3 也处理存储地址操作)。 为了利用此带宽,数据必须保留在 L1 数据缓存中,否则应按顺序访问,从而使硬件预取器能够及时将数据带到 L1 数据缓存中。

考虑以下计算数组所有元素和的 C 代码示例:

int buff[BUFF_SIZE];
int sum = 0;

for (i=0;i<BUFF_SIZE;i++){ 
  sum+=buff[i];
}

示例 1-1 是英特尔编译器为此 C 代码生成的汇编代码。 编译器使用英特尔 SSE 指令对执行进行矢量化。 在此代码中,每个 ADD 操作都使用前一个 ADD 操作的结果。 这将吞吐量限制为每个周期一个加载和 ADD 操作。 示例 1-2 针对 Sandy Bridge 微架构进行了优化,使其能够使用额外的加载带宽。 该代码通过使用两个寄存器来对数组值求和,从而消除了 ADD 操作之间的依赖性。 每个周期可以执行两次加载和两次添加操作。

示例 1-1

xor eax, eax
  pxor xmm0, xmm0
  lea rsi, buff

loop_start:
  paddd xmm0, [rsi+4*rax]
  paddd xmm0, [rsi+4*rax+16]
  paddd xmm0, [rsi+4*rax+32]
  paddd xmm0, [rsi+4*rax+48]
  paddd xmm0, [rsi+4*rax+64]
  paddd xmm0, [rsi+4*rax+80]
  paddd xmm0, [rsi+4*rax+96]
  paddd xmm0, [rsi+4*rax+112]
  add eax, 32
  cmp eax, BUFF_SIZE
  jl loop_start
sum_partials:
  movdqa xmm1, xmm0
  psrldq xmm1, 8
  paddd xmm0, xmm1
  movdqa xmm2, xmm0
  psrldq xmm2, 4
  paddd xmm0, xmm2
  movd [sum], xmm0

示例 1-2

  xor eax, eax
  pxor xmm0, xmm0
  pxor xmm1, xmm1
  lea rsi, buff

loop_start:
  paddd xmm0, [rsi+4*rax]
  paddd xmm1, [rsi+4*rax+16]
  paddd xmm0, [rsi+4*rax+32]
  paddd xmm1, [rsi+4*rax+48]
  paddd xmm0, [rsi+4*rax+64]
  paddd xmm1, [rsi+4*rax+80]
  paddd xmm0, [rsi+4*rax+96]
  paddd xmm1, [rsi+4*rax+112]
  add eax, 32
  cmp eax, BUFF_SIZE
  jl loop_start
sum_partials:
  paddd xmm0, xmm1
  movdqa xmm1, xmm0
  psrldq xmm1, 8
  paddd xmm0, xmm1
  movdqa xmm2, xmm0
  psrldq xmm2, 4
  paddd xmm0, xmm2
  movd [sum], xmm0

1.2 Sandy Bridge 微架构中的 L1D 缓存延迟

L1D 缓存的加载延迟可能会有所不同, 最好的情况是 4 个周期,这适用于使用以下方法之一对通用寄存器进行加载操作:

  • 一个寄存器。
  • 一个基址寄存器加上一个小于 2048 的偏移量。

考虑示例中的指针跟踪代码示例。

示例 1-3: Traversing through indexes

// C code example
index = buffer.m_buff[index].next_index; 
// ASM example
loop:
  shl rbx, 6
  mov rbx, 0x20(rbx+rcx) 
  dec rax
  cmp rax, -1
  jne loop

示例 1-4: Traversing through pointers

// C code example
node = node->pNext;
// ASM example 
loop:
  mov rdx, [rdx] 
  dec rax
  cmp rax, -1 
  jne loop

示例 1-3 通过遍历索引实现指针追踪。 然后编译器生成所示的代码,使用带有偏移量的 base+index 寻址内存。 示例 1-4 显示了编译器从指针解引用代码生成的代码,并且仅使用了一个基址寄存器。在 Sandy Bridge 微架构和之前的微架构中,代码 2 比代码 1 要快。

1.3 处理 L1D 缓存库冲突

在 Sandy Bridge 微架构中,L1D 缓存的内部组织会出现两个加载地址,可能存在库冲突的微操作的情况。当两个加载操作之间存在冲突时,最近的一个将被延迟,直到冲突解决。当两个同时加载操作具有相同的线性地址的第 2-5 位但它们不是来自高速缓存中的同一组(第 6-12 位)时,就会发生库冲突。

只有当代码受加载带宽约束时,才应处理库冲突。一些库冲突不会导致任何性能下降,因为它们被其他性能限制隐藏,消除这种库冲突并不能提高性能。

以下示例演示了库冲突以及如何修改代码并避免它们。它使用两个源数组,其大小是缓存行大小的倍数。当从 A 加载一个元素并从 B 加载对应元素时,这些元素在它们的缓存行中具有相同的偏移量,因此可能会发生存储库冲突。 L1D 缓存库冲突不适用于 Haswell 微架构。

示例 1-5:C Code

int A[128];
int B[128];
int C[128];
for (i=0;i<128;i+=4){
  C[i]=A[i]+B[i]; // the loads from A[i] and B[i] collide
  C[i+1]=A[i+1]+B[i+1];
  C[i+2]=A[i+2]+B[i+2];
  C[i+3]=A[i+3]+B[i+3];
}

示例 1-6: Code with Bank Conflicts

  xor rcx, rcx
  lea r11, A
  lea r12, B
  lea r13, C
loop:
  lea esi, [rcx*4]
  movsxd rsi, esi
  mov edi, [r11+rsi*4]
  add edi, [r12+rsi*4]
  mov r8d, [r11+rsi*4+4]
  add r8d, [r12+rsi*4+4]
  mov r9d, [r11+rsi*4+8]
  add r9d, [r12+rsi*4+8]
  mov r10d, [r11+rsi*4+12]
  add r10d, [r12+rsi*4+12]

  mov [r13+rsi*4], edi
  inc ecx
  mov [r13+rsi*4+4], r8d
  mov [r13+rsi*4+8], r9d
  mov [r13+rsi*4+12], r10d
  cmp ecx, LEN
  jb loop

示例 1-7: Code without Bank Conflicts

 xor rcx, rcx
  lea r11, A
  lea r12, B
  lea r13, C
loop:
  lea esi, [rcx*4]
  movsxd rsi, esi
  mov edi, [r11+rsi*4]
  mov r8d, [r11+rsi*4+4]
  add edi, [r12+rsi*4]
  add r8d, [r12+rsi*4+4]
  mov r9d, [r11+rsi*4+8]
  mov r10d, [r11+rsi*4+12]
  add r9d, [r12+rsi*4+8]
  add r10d, [r12+rsi*4+12]
  
  inc ecx
  mov [r13+rsi*4], edi
  mov [r13+rsi*4+4], r8d
  mov [r13+rsi*4+8], r9d
  mov [r13+rsi*4+12], r10d
  cmp ecx, LEN
  jb loop

2 尽量减少寄存器溢出

当一段代码的实时变量多于处理器可以保存在通用寄存器中的数量时,一种常见的方法是将一些变量保存在内存中。 这种方法称为寄存器溢出。 L1D 缓存延迟的影响会对该代码的性能产生负面影响。 如果寄存器溢出的地址使用较慢的寻址模式,效果会更加明显。

一种选择是将通用寄存器溢出到 XMM 寄存器。 这种方法也可能提高前几代处理器的性能。 以下示例显示如何将寄存器溢出到 XMM 寄存器而不是内存。

示例 2-1:Register spills into memory

loop:
  mov rdx, [rsp+0x18]
  movdqa xmm0, [rdx]
  movdqa xmm1, [rsp+0x20]
  pcmpeqd xmm1, xmm0
  pmovmskb eax, xmm1
  test eax, eax
  jne end_loop
  movzx rcx, [rbx+0x60]

  add qword ptr[rsp+0x18], 0x10
  add rdi, 0x4
  movzx rdx, di
  sub rcx, 0x4
  add rsi, 0x1d0
  cmp rdx, rcx
  jle loop

Register spills into XMM

  movq xmm4, [rsp+0x18]
  mov rcx, 0x10
  movq xmm5, rcx
loop:
  movq rdx, xmm4
  movdqa xmm0, [rdx]
  movdqa xmm1, [rsp+0x20]
  pcmpeqd xmm1, xmm0
  pmovmskb eax, xmm1
  test eax, eax
  jne end_loop
  movzx rcx, [rbx+0x60]

  padd xmm4, xmm5
  add rdi, 0x4
  movzx rdx, di
  sub rcx, 0x4
  add rsi, 0x1d0
  cmp rdx, rcx
  jle loop

3 增强推测执行和内存消歧

在 Intel Core 微架构之前,当代码同时包含存储和加载时,在知道旧存储的地址之前无法发出加载。此规则确保正确处理对先前存储的加载依赖关系。

Intel Core 微架构包含一种机制,允许在存在较旧的未知存储的情况下推测性地执行某些加载。处理器稍后检查加载地址是否与执行加载时地址未知的旧存储重叠。如果地址确实重叠,则处理器重新执行加载和所有后续指令。

示例代码说明了编译器无法确定”Ptr->Array”在循环期间不会改变的情况。因此,编译器不能将”Ptr->Array”作为不变量保存在寄存器中,并且必须在每次迭代中再次读取它。虽然这种情况可以通过重写代码以要求指针地址不变在软件中修复,但内存消歧在不重写代码的情况下提高了性能。

示例 3-1:Loads Blocked by Stores of Unknown Address

// C code
struct AA {
  AA ** Array;
};
void nullify_array ( AA *Ptr, DWORD Index, AA *ThisPtr)
{
  while ( Ptr->Array[--Index] != ThisPtr )
  {
    Ptr->Array[Index] = NULL ;
  } ;
} ;

// Assembly sequence
  nullify_loop:
  mov dword ptr [eax], 0
  mov edx, dword ptr [edi]
  sub ecx, 4
  cmp dword ptr [ecx+edx], esi
  lea eax, [ecx+edx]
  jne nullify_loop

4 存储转发

处理器的内存系统仅在存储失效后将存储发送到内存(包括缓存)。但是,存储数据可以从同一地址从存储转发到后续加载,以缩短存储加载延迟。

存储转发有两种要求。如果违反了这些要求,存储转发将无法发生,加载必须从缓存中获取数据(因此存储必须先将其数据写回缓存)。这会带来很大程度上与底层微架构的管道深度有关的惩罚。

第一个要求与存储转发数据的大小和对齐方式有关。 此限制可能会对整体应用程序性能产生很大影响。 通常,可以防止因违反此限制而导致的性能损失。 存储到加载转发限制因一种微架构而异。 第 4.1 节“存储到加载转发对大小和对齐的限制”中详细讨论了几个导致存储转发停滞的编码缺陷示例以及这些缺陷的解决方案。 第二个要求是数据的可用性,在第 4.2 节“数据可用性的存储转发限制”中进行了讨论。 一个好的做法是消除冗余的加载操作。

可以将临时标量变量保存在寄存器中,而永远不要将其写入内存。 通常,这样的变量不能使用间接指针访问。 将变量移动到寄存器会消除该变量的所有加载和存储,并消除与存储转发相关的潜在问题。 然而,它也增加了套准压力。

加载指令倾向于启动计算链。 由于乱序引擎是基于数据依赖的,因此加载指令对引擎的高速执行能力起着重要作用。 消除加载指令应该是高度优先的。

如果一个变量从存储到再次使用之间没有变化,则可以复制或直接使用之前存储的寄存器。 如果寄存器压力太大,或者在存储和第二次加载之前调用了一个看不见的函数,则可能无法消除第二次加载。

尽可能在寄存器中而不是在堆栈中传递参数。 在堆栈上传递参数需要存储然后重新加载。 虽然此序列在硬件中通过直接从内存顺序缓冲区向加载提供值而在硬件中进行了优化,如果存储转发限制允许,则无需访问数据缓存,但浮点值会在转发过程中产生显着延迟。 在(最好是 XMM)寄存器中传递浮点参数应该可以节省这种长延迟操作。

参数传递约定可能会限制哪些参数在堆栈上传递,哪些参数在寄存器中传递。 但是,如果编译器可以控制整个二进制文件的编译(使用整个程序优化),则可以克服这些限制。

4.1 Store-to-Load-Forwarding 大小和对齐限制

存储转发的数据大小和对齐限制适用于基于 Intel Core 微架构、Intel Core 2 Duo、Intel Core Solo 和 Pentium M 处理器的处理器。 对于较短的流水线机器,违反存储转发限制的性能损失较小。

存储转发限制因每个微架构而异。 以下规则有助于满足存储转发的大小和对齐限制:

  • 规则1:从存储转发的加载必须具有相同的地址起点,因此与存储数据具有相同的对齐方式。
  • 规则2:从存储转发的加载数据必须完全包含在存储数据中。

从存储转发的加载必须等待存储的数据写入存储缓冲区才能继续,但其他不相关的加载不需要等待。

  • 规则3:如果需要提取存储数据的未对齐部分,请读出完全包含数据的最小对齐部分,并根据需要 shift/mask 数据。 这比招致存储转发失败的惩罚要好。
  • 规则4:通过根据需要使用单个大型读取和注册副本,避免在将大型存储到同一内存区域之后进行几次小型加载。

示例 4-1 描述了几种存储转发情况,其中小加载跟随大存储。 前三个加载操作说明了规则 4 中描述的情况。但是,最后一个加载操作从存储转发中获取数据没有问题。

示例 4-1:Situations Showing Small Loads After Large Store

mov [EBP],‘abcd’
mov AL, [EBP] ; Not blocked - same alignment
mov BL, [EBP + 1] ; Blocked
mov CL, [EBP + 2] ; Blocked
mov DL, [EBP + 3] ; Blocked
mov AL, [EBP] ; Not blocked - same alignment
              ; n.b. passes older blocked loads

示例 4-2 说明了存储转发情况,其中大加载跟随几个小存储。 加载操作所需的数据无法转发,因为需要转发的所有数据都没有包含在存储缓冲区中。 在小存储到同一内存区域后避免大加载。

示例 4-2:Non-forwarding Example of Large Load After Small Store

mov [EBP], ‘a’
mov [EBP + 1], ‘b’
mov [EBP + 2], ‘c’
mov [EBP + 3], ‘d’
mov EAX, [EBP] ; Blocked
    ; The first 4 small store can be consolidated into
    ; a single DWORD store to prevent this non-forwarding
    ; situation.

示例 4-3 说明了可能出现在编译器生成的代码中的停滞存储转发情况。 有时,编译器会生成类似于示例 3 中所示的代码来处理溢出的字节到堆栈并将字节转换为整数值。

示例 4-3:A Non-forwarding Situation in Compiler Generated Code

mov DWORD PTR [esp+10h], 00000000h
mov BYTE PTR [esp+10h], bl
mov eax, DWORD PTR [esp+10h] ; Stall
and eax, 0xff ; Converting back to byte value

示例 4-5 提供了两种替代方案来避免示例 3 中所示的非转发情况。

示例 4-5:Two Ways to Avoid Non-forwarding Situation in Example 3

; A. Use MOVZ instruction to avoid large load after small
; store, when spills are ignored.
movz eax, bl ; Replaces the last three instructions
; B. Use MOVZ instruction and handle spills to the stack
mov DWORD PTR [esp+10h], 00000000h
mov BYTE PTR [esp+10h], bl
movz eax, BYTE PTR [esp+10h] ; Not blocked

在内存位置之间移动小于 64 位的数据时,64 位或 128 位 SIMD 寄存器移动效率更高(如果对齐),可用于避免未对齐的加载。 尽管浮点寄存器允许一次移动 64 位,但浮点指令不应用于此目的,因为数据可能会被无意修改。

示例 4-5:Large and Small Load Stalls

; A. Large load stall
mov mem, eax ; Store dword to address “MEM"
mov mem + 4, ebx ; Store dword to address “MEM + 4"
fld mem ; Load qword at address “MEM", stalls
; B. Small Load stall
fstp mem ; Store qword to address “MEM"
mov bx, mem+2 ; Load word at address “MEM + 2", stalls
mov cx, mem+4 ; Load word at address “MEM + 4", stalls

在第一种情况 (A) 中,在对同一内存区域(从内存地址 MEM 开始)进行一系列小存储之后,会出现大加载。 大加载将停止。

FLD 必须等待存储写入内存,然后才能访问所需的所有数据。 这种停顿也可能发生在其他数据类型中(例如,当存储字节或字,然后从同一内存区域读取字或双字时)。

在第二种情况 (B) 中,在大存储到同一内存区域(从内存地址 MEM 开始)之后,会有一系列小加载。 小加载将停止。

字加载必须等待四字存储写入内存,然后才能访问所需的数据。 这种停顿也可能发生在其他数据类型中(例如,当存储双字或字,然后从同一内存区域读取字或字节时)。 这可以通过将商店尽可能远离加载来避免。

4.2

要存储的值必须在加载操作完成之前可用。 如果违反此限制,加载的执行将被延迟,直到数据可用。 这种延迟会导致一些执行资源被不必要地使用,这可能会导致相当大但不确定的延迟。 然而,这个问题的整体影响远小于违反尺寸和对齐要求的影响。

在现代微架构中,硬件预测加载何时依赖并从之前的存储中获取数据。 这些预测可以显着提高性能。 但是,如果在它所依赖的存储之后过早地安排加载,或者如果要存储的数据的生成被延迟,则可能会产生重大损失。

数据通过内存传递有几种情况,可能需要将存储与加载分开:

  • 溢出、保存和恢复堆栈帧中的寄存器。
  • 参数传递。
  • 全局变量和 volatile 变量。
  • 整数和浮点之间的类型转换。
  • 当编译器不分析内联代码时,强制内联代码接口中涉及的变量位于内存中,从而创建更多内存变量并防止消除冗余负载。

如果可以在不招致其他惩罚的情况下,请优先考虑将变量分配给寄存器,例如在寄存器分配和参数传递中,以最大限度地减少存储转发问题的可能性和影响。 尽量不要存储转发从长延迟指令生成的数据 – 例如,MUL 或 DIV。 避免为具有最短存储加载距离的变量存储转发数据。 避免为具有许多 and/or 长依赖链的变量存储转发数据,尤其是避免在循环携带的依赖链上包含存储转发。示例 4-6 展示了一个循环携带的依赖链的例子。

示例 4-6:Loop-carried Dependence Chain

for ( i = 0; i < MAX; i++ ) {
  a[i] = b[i] * foo;
  foo = a[i] / 3;
} // foo is a loop-carried dependence.

尽早计算存储地址以避免存储块加载。

5 数据布局优化

填充源代码中定义的数据结构,以便每个数据元素都与自然操作数大小的地址边界对齐。如果操作数打包在 SIMD 指令中,则与打包元素大小(64 位或 128 位)对齐。

通过在结构和数组内部提供填充来对齐数据。 程序员可以重新组织结构和数组,以尽量减少填充浪费的内存量。 但是,编译器可能没有这种自由。 例如,C 编程语言指定结构元素在内存中的分配顺序。

示例 5-1 显示了如何重新排列数据结构以减小其大小。

示例 5-1:Rearranging a Data Structure

struct unpacked { /* Fits in 20 bytes due to padding */
  int a;
  char b;
  int c;
  char d;
  int e;
};
struct packed { /* Fits in 16 bytes */
  int a;
  int c;
  int e;
  char b;
  char d;
}

64 字节的高速缓存行大小会影响流应用程序(例如多媒体)。 这些在丢弃数据之前仅引用和使用一次数据。 稀疏地利用高速缓存行内的数据的数据访问会导致系统内存带宽的利用效率降低。 例如,可以将结构数组分解为多个数组以实现更好的打包,如例 5-2 所示。

示例 5-2:Decomposing an Array

struct { /* 1600 bytes */
  int a, c, e;
  char b, d;
} array_of_struct [100];
struct { /* 1400 bytes */
  int a[100], c[100], e[100];
  char b[100], d[100];
} struct_of_array;
struct { /* 1200 bytes */
  int a, c, e;
} hybrid_struct_of_array_ace[100];
struct { /* 200 bytes */
  char b, d;
} hybrid_struct_of_array_bd[100];

这种优化的效率取决于使用模式。 如果结构的元素全部一起访问,但数组的访问模式是随机的,那么 ARRAY_OF_STRUCT 会避免不必要的预取,即使它会浪费内存。

但是,如果数组的访问模式表现出局部性(例如数组索引被扫描),那么具有硬件预取器的处理器将从 STRUCT_OF_ARRAY 预取数据,即使结构的元素被一起访问。

当结构的元素不是以相同的频率访问时,例如当元素 A 的访问频率是其他条目的十倍时,STRUCT_OF_ARRAY 不仅可以节省内存,还可以防止获取不必要的数据项 B、C、D 和E。

使用 STRUCT_OF_ARRAY 还允许程序员和编译器使用 SIMD 数据类型。

请注意,STRUCT_OF_ARRAY 的缺点是需要更多独立的内存流引用。 这可能需要使用更多的预取和额外的地址生成计算。 它还会对 DRAM 页面访问效率产生影响。 另一种方法是 HYBRID_STRUCT_OF_ARRAY 混合了这两种方法。 在这种情况下,仅生成和引用 2 个单独的地址流:1 个用于 HYBRID_STRUCT_OF_ARRAY_ACE,1 个用于 HYBRID_STRUCT_OF_ARRAY_BD。 第二个替代方案还可以防止获取不必要的数据——假设 (1) 变量 A、C 和 E 总是一起使用,以及 (2) 变量 B 和 D 总是一起使用,但与 A、C 和 E 不同时使用 。

混合方法确保:

  • 比 STRUCT_OF_ARRAY 更简单/更少的地址生成。
  • 更少的流,从而减少了 DRAM 页面缺失。
  • 由于流更少,预取更少。
  • 同时使用的数据元素的高效缓存行打包。

尝试安排数据结构,使它们允许顺序访问。如果将数据排列成一组流,则自动硬件预取器可以预取应用程序需要的数据,从而减少有效的内存延迟。 如果以非顺序方式访问数据,则自动硬件预取器无法预取数据。 预取器最多可以识别八个并发流。当心高速缓存行(64 字节)内的错误共享。

6 堆栈对齐

当内存引用拆分缓存线时,会发生对堆栈的未对齐访问的性能损失。这意味着八个空间上连续的未对齐四字访问中有一个总是受到惩罚,类似于四个连续的、未对齐的双四字访问中的一个。

当数据对象超过系统的默认堆栈对齐方式时,对齐堆栈可能是有益的。例如,在32/64位Linux和64位Windows上,默认堆栈对齐为16字节,而32位Windows为4字节。

确保堆栈在与寄存器宽度匹配的最大多字节粒度数据类型边界处对齐。对齐堆栈通常需要使用额外的寄存器来跟踪未知数量的填充区域。在导致跨越缓存线的未对齐内存引用和导致额外的通用寄存器溢出之间存在权衡。实现动态堆栈对齐的汇编级技术可能取决于编译器和特定的操作系统环境。

示例 6-1:Examples of Dynamical Stack Alignment

// 32-bit environment
push ebp ; save ebp
mov  ebp, esp ; ebp now points to incoming parameters
andl esp, $-<N> ;align esp to N byte boundary
sub  esp, $<stack_size>; reserve space for new stack frame
. ; parameters must be referenced off of ebp
mov  esp, ebp ; restore esp
pop  ebp ; restore ebp

// 64-bit environment
sub  esp, $<stack_size +N>
mov  r13, $<offset_of_aligned_section_in_stack>
andl r13, $-<N> ; r13 point to aligned section in stack
. ;use r13 as base for aligned data

如果由于某种原因无法将堆栈对齐64位,则例程应访问该参数并将其保存到寄存器或已知的对齐存储器中,从而只会导致一次惩罚。

7 缓存中的容量限制和别名

在某些情况下,具有给定步幅的地址将竞争内存层次结构中的某些资源。 通常,缓存被实现为具有多种方式的集合关联性,每种方式由多组缓存行(或某些情况下的扇区)组成。 多个内存引用在缓存中竞争同一组的每个方式可能会导致容量问题。 有适用于特定微架构的别名条件。 请注意,一级缓存行是 64 字节。 因此,在别名比较中不考虑最低有效 6 位。

8 混合代码和数据

英特尔处理器对指令的主动预取和预解码有两个相关影响:

  • 根据英特尔体系结构处理器的要求,自修改代码可以正常工作,但会导致严重的性能损失。尽可能避免自我修改代码。
  • 在代码段中放置可写数据可能无法与自修改代码区分开来。代码段中的可写数据可能会受到与自修改代码相同的性能损失。

如果(希望是只读的)数据必须与代码出现在同一页上,请避免将其直接放在间接跳转之后。例如,跟随一个间接跳转及其最可能的目标,并将数据放在一个无条件分支之后。

在极少数情况下,将代码页上的数据作为指令执行可能会导致性能问题。当执行遵循不驻留在跟踪缓存中的间接分支时,很可能会发生这种情况。如果这明显导致性能问题,请尝试将数据移到其他位置,或在间接分支后立即插入非法操作码或暂停指令。请注意,后两种备选方案在某些情况下可能会降低性能。

始终将代码和数据放在单独的页面上。尽可能避免自我修改代码。如果要修改代码,请尝试一次完成所有操作,并确保执行修改的代码和被修改的代码位于单独的4kb页面或单独对齐的1kb子页面上。

8.1 自修改代码(Self-modifying Code)

在 Pentium III 处理器和之前的实现上正确运行的自修改代码(SMC)将在后续实现上正确运行。当需要高性能时,应避免SMC和交叉修改代码(当多处理器系统中的多个处理器写入代码页时)。

软件应避免写入正在执行的同一个1KB子页面中的代码页,或获取正在写入的同一个2KB子页面中的代码。此外,将包含直接或推测执行代码的页面作为数据页面与另一个处理器共享可能会触发SMC条件,从而导致机器的整个管道和跟踪缓存被清除。这是由于自修改代码条件造成的。

如果写入的代码在作为代码访问数据页之前填充了该页,则动态代码不必导致SMC情况。动态修改的代码(例如,来自目标修复)可能会受到SMC条件的影响,应尽可能避免。通过引入间接分支和使用register间接调用在数据页(而不是代码页)上使用数据表来避免这种情况。

8.2 位置无关代码

位置无关的代码通常需要获取指令指针的值。示例8-1a显示了一种通过发出不带匹配RET的调用将IP值放入ECX寄存器的技术。示例8-1b显示了另一种使用匹配的CALL/RET对将IP值放入ECX寄存器的技术。

示例 8-1:Instruction Pointer Query Techniques

a) Using call without return to obtain IP does not corrupt the RSB
    call _label; return address pushed is the IP of next instruction
_label:
    pop ECX; IP of this instruction is now put into ECX
b) Using matched call/ret pair
    call _lblcx;
    ... ; ECX now contains IP of this instruction
    ...
_lblcx
    mov ecx, [esp];
    ret

9 写组合

写组合(WC)通过两种方式提高性能:

  • 在一级缓存的写未命中时,它允许在缓存线从缓存/内存层次结构的更外层读取所有权(RFO)之前,对同一缓存线进行多个存储。然后读取行的其余部分,并将尚未写入的字节与返回行中未修改的字节组合。
  • 写入组合允许在高速缓存层次结构中将多个写入组合并作为一个单元进一步写入。 这节省了端口和总线流量。节省流量对于避免部分写入未缓存的内存尤为重要。

基于英特尔 Core 微架构的处理器在每个内核中有八个写入组合缓冲区。 从 Nehalem 微架构开始,有 10 个缓冲区可用于写入组合。 从 Ice Lake 客户端微架构开始,有 12 个缓冲区可用于写入组合。

如果内部循环写入超过四个数组(四个不同的缓存行),则应用循环分裂来分解循环体,以便在每个结果循环的每次迭代中只写入四个数组。

写组合缓冲区用于所有内存类型的存储。 它们对于对未缓存内存的写入特别重要:对同一缓存行的不同部分的写入可以分组到单个完整的缓存行总线事务中,而不是像多个部分写入那样通过总线(因为它们没有被缓存) . 避免部分写入会对受总线带宽限制的图形应用程序产生重大影响,其中图形缓冲区位于未缓存的内存中。 将对未缓存内存的写入和对回写内存的写入分离到单独的阶段可以确保写入组合缓冲区可以在被其他写入流量驱逐之前填满。 已发现消除部分写入事务对某些应用程序的性能影响约为 20%。 因为高速缓存行是 64 字节,所以写入总线 63 字节将导致部分总线事务。

在编写同时在两个线程上执行的函数时,减少内部循环中允许的写入次数将有助于充分利用写入组合存储缓冲区。

存储顺序和可见性也是写入组合的重要问题。 当对先前未写入的高速缓存行的写入组合缓冲区进行写入时,将发生读取所有权 (RFO)。 如果后续写入发生在另一个写入组合缓冲区,则可能会为该高速缓存行导致单独的 RFO。 对第一个高速缓存行和写入组合缓冲区的后续写入将被延迟,直到第二个 RFO 得到服务,以保证写入的正确排序可见性。 如果写入的内存类型是写入组合,则不会有 RFO,因为该行没有被缓存,并且没有这样的延迟。

10 局部增强

局部性增强可以减少来自缓存/内存层次结构中的外部子系统的数据流量。这是为了解决这样一个事实,即从外部层面的周期计数来看,访问成本将比从内部层面的成本更高。通常,访问给定缓存级别(或内存系统)的周期成本因不同的微体系结构、处理器实现和平台组件而异。按地区识别相对数据访问成本趋势可能就足够了,而不是按照每个地区、每个处理器/平台实施列出的周期成本的大型数值表,等。一般趋势是,假设数据访问并行度相似,从外部子系统访问数据的成本可能比从缓存/内存层次结构中的直接内部级别访问数据的成本大约高3-10倍。

即使最后一级缓存的缓存未命中率相对于缓存引用的数量可能较低,处理器通常会花费相当大一部分执行时间等待缓存未命中得到服务。通过增强程序的局部性来减少缓存未命中是一个关键的优化。这可以采取几种形式:

  • 阻塞以迭代将适合缓存的数组的一部分(目的是对数据块 [或 tile] 的后续引用将成为缓存命中引用)。
  • 循环交换以避免跨越高速缓存行或页面边界。
  • 循环倾斜以使访问连续。

可以通过对数据访问模式进行排序以利用硬件预取来实现对最后一级缓存的局部性增强。 这也可以采取多种形式:

  • 将稀疏填充的多维数组转换为一维数组,以便内存引用以对硬件预取友好的顺序、小步幅模式发生。
  • 最佳切片大小和形状选择可以通过提高最后一级缓存的命中率和减少硬件预取操作导致的内存流量来进一步改善时间数据局部性。

避免对局部性增强技术起作用的操作很重要。 在访问内存时,无论数据是在缓存中还是在系统内存中,大量使用锁定前缀都会导致很大的延迟。

阻塞、循环交换、循环倾斜和打包等优化技术最好由编译器完成。 优化数据结构以适应一级缓存的一半或二级缓存; 在编译器中打开循环优化以增强嵌套循环的局部性。

优化一半的一级缓存将在每次数据访问的周期成本方面带来最大的性能优势。 如果一级缓存的一半太小不实用,则针对二级缓存进行优化。 针对中间的一点进行优化(例如,针对整个一级缓存)可能不会比针对二级缓存的优化带来实质性的改进。

11 非临时存储总线流量

峰值系统总线带宽由几种类型的总线活动共享,包括读取(从内存)、读取所有权(缓存行)和写入。 如果一次将 64 个字节写入总线,则总线写事务的数据传输率会更高。

通常,写入回写 (WB) 内存的总线必须与读取所有权 (RFO) 流量共享系统总线带宽。 非临时存储不需要 RFO 流量; 它们确实需要小心管理访问模式,以确保一次收回 64 个字节(而不是收回多个块)。

尽管由于非临时存储而导致的完整 64 字节总线写入的数据带宽是总线写入 WB 内存的两倍,但传输多个块会浪费总线请求带宽并提供显着降低的数据带宽。 这种差异在示例 11-1 和 11-2 中进行了描述。

示例 11-1:Using Non-temporal Stores and 64-byte Bus Write Transactions

#define STRIDESIZE 256
lea ecx, p64byte_Aligned
mov edx, ARRAY_LEN
xor eax, eax
slloop:
movntps XMMWORD ptr [ecx + eax], xmm0
movntps XMMWORD ptr [ecx + eax+16], xmm0
movntps XMMWORD ptr [ecx + eax+32], xmm0
movntps XMMWORD ptr [ecx + eax+48], xmm0

; 64 bytes is written in one bus transaction
add eax, STRIDESIZE
cmp eax, edx
jl slloop

示例 11-2:On-temporal Stores and Partial Bus Write Transactions

#define STRIDESIZE 256
Lea ecx, p64byte_Aligned
Mov edx, ARRAY_LEN
Xor eax, eax
slloop:
movntps XMMWORD ptr [ecx + eax], xmm0
movntps XMMWORD ptr [ecx + eax+16], xmm0
movntps XMMWORD ptr [ecx + eax+32], xmm0

; Storing 48 bytes results in several bus partial transactions
add eax, STRIDESIZE
cmp eax, edx
jl slloop