跳转至

笔记

Pytorch 4 :Save & Load & Pretrain

导言

  • 保存与加载模型:学习如何保存训练好的模型,并在需要时加载模型进行推理或继续训练。
  • 迁移学习:学习如何使用预训练模型进行迁移学习,微调模型以适应新的任务。
  • 常用预训练模型:介绍PyTorch中常用的预训练模型,如ResNet、VGG等。

Lock & Synchronization

导言

  • 在PTA开发时,其中的多线程加速经常要用锁来隔离资源和逻辑。

互斥与同步

在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。

为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法:

  • 基于原子操作指令(汇编) —— 测试和置位(Test-and-Set)指令, 或者(如Compare-And-Swap,CAS)
  • 锁:加锁、解锁操作;
    • 自旋锁(spin lock, 忙等待锁),
    • 无等待锁:思想,把当前线程放入到锁的等待队列,然后执行调度程序
  • 信号量:P、V 操作;

这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。

常见问题

共享内存加锁之后释放锁,别的进程是如何知道锁释放的

  1. 常用的方法是在共享内存中设置标志位或信号量等,并在共享内存中保证这个标志位或信号量只有在锁被释放时才会被更新。这样,其它进程可以通过轮询或者等待通知的方式来获取锁并开始修改共享内存,从而避免冲突。在共享内存中设置的标志位或信号量通常需要原子操作的支持,以确保并发修改时的正确性。
    1. 轮询:轮询是指线程反复检查某个条件是否成立,直到条件成立为止。在锁机制中,当一个线程持有锁时,其他线程会不断地轮询锁的状态,直到该锁被释放。这种方式的优点是实现简单,不需要额外的通知机制,缺点是占用CPU资源,效率较低。
    2. 等待通知:等待通知是指线程在某个条件不满足时挂起等待,当条件满足时由其他线程通知它继续执行。在锁机制中,当一个线程持有锁时,其他线程会进入等待状态,直到该锁被释放,此时其他线程会被通知并继续执行。这种方式的优点是占用CPU资源少,效率高,缺点是实现稍微复杂一些,需要额外的通知机制。
  2. 另外,也可以使用一个中央锁服务器或者等待队列来管理锁,当一个进程获取锁时,会向中央锁服务器或等待队列发出请求,直到锁被成功获取,并在共享内存中记录锁的状态。当锁被释放时,中央锁服务器或等待队列会通知其它进程,并让其它进程开始自由修改共享内存。

如何保证操作的原子性

  1. 操作系统提供的原子操作:一些操作系统会提供线程安全的原子操作接口,如Compare-And-Swap(CAS)等,它们能够确保指令的原子性,从而保证线程安全。
  2. 事务:事务是指一组操作被视为一个不可分割的操作序列,要么全部执行成功,要么全部失败,具有原子性和一致性保证。常用于数据库操作等场景。
  3. 锁机制:锁机制是一种常用的多线程同步机制,能够确保同一时刻只有一个线程(或进程)可以访问共享资源,从而保证原子性。

如何避免死锁

  1. 避免使用多把锁并且同时持有多个锁。当需要持有多个锁时,可以通过加锁的顺序来避免死锁。如果所有可能的锁按照固定的顺序加锁,那么可以避免死锁。
  2. 设置请求超时时间。当一个进程请求锁时,如果在超时时间内没有获得锁,可以释放之前持有的锁,并尝试重新获取。这样可以避免某一个进程一直持有锁而导致死锁。
  3. 引入随机性。在获取锁的时候加入一些随机因素,让不同的程序在不同的时间获取锁。这样可以防止程序之间在自己的重试过程中的饥饿状态导致的死锁。

悲观锁

  1. 读写操作时,需要预先加锁,防止其他进程对资源的访问。
  2. 通过互斥锁(Mutex)和信号量(Semaphore)来实现。

乐观锁

  1. 在读取或修改共享资源时,并不先进行加锁操作,而是先读取资源,然后在对资源进行写操作时再进行一次比较,看看在这个时间间隔内是否发生了竞争。如果没有发生竞争,就可以将更新后的值写入共享资源,并结束操作;如果发生了竞争,则需要放弃本次更新,并进行重试
  2. 通过版本号的方式来实现。在共享资源中记录该资源的版本号,当一个进程想要修改共享资源时,需要先获取当前资源的版本号。如果当前版本号与自己保存的版本号相符,说明没有其他进程在这段时间内修改该资源,则可以进行写操作;如果版本号已经发生变化,则说明有其他进程对该资源进行了修改,当前进程需要放弃本次写操作,更新版本号,重新获取新的资源,并重新执行操作。

原子操作

Test-and-SetCompare-and-Swap (CAS) 都是并发编程中的重要原子操作指令,通常用于同步和处理多线程或多进程环境中的竞争条件。它们的作用是确保某些操作在执行时不会被中断,以保持数据一致性和避免并发冲突。我们来详细了解这两个命令。

1. Test-and-Set (TAS) 指令

  • 通常用于控制锁定状态,通过设置标志位来获取锁,它返回原始值(通常是锁状态),用于判断锁是否已经被占用。
  • 它通常用于实现自旋锁、互斥锁(mutex)或锁标志位。

它的操作过程如下:

  • Test: 检查指定位置(通常是一个布尔标志位或整数)当前的值。
  • Set: 将这个位置的值设置为 1 或 "true"。

操作是原子的,即不会被中断或干扰,保证在并发环境下,多个线程或进程不能同时修改同一位置。

用途:

  • 实现自旋锁(Spinlock):线程反复检查一个共享变量,如果该变量表示未被锁定,线程会尝试获得锁。
  • 防止竞争条件:确保在并发操作时,只有一个线程能够设置锁标志,其他线程需要等待。
示例

假设有一个共享的锁标志 lock,初始化为 false

lock = false;

while (Test_and_Set(lock)) {
    // 如果 lock 已经被设置为 true,则继续自旋等待
}
// lock 成功设置为 true,获取锁

2. Compare-and-Swap (CAS) 指令

  • 通常用于无锁编程,它通过比较期望值与当前值来决定是否进行更新(检查指定的内存位置的值是否等于一个预期的值,如果相等,则将该位置的值替换为新的值),适用于复杂的数据结构操作。
  • 常用于无锁数据结构和其他更高效的并发操作。

它的操作步骤如下:

  • Compare: 比较目标内存位置的当前值是否与预期值相等。
  • Swap: 如果当前值等于预期值,就将目标位置的值替换为新的值。如果不相等,则什么也不做。

CAS 也具有原子性,这意味着它在操作期间不会被中断。CAS 是实现无锁编程和避免线程同步问题的常用工具,尤其在需要高性能的并发程序中。

用途:

  • 实现无锁数据结构(如无锁队列、栈、哈希表等)。
  • 实现线程安全的计数器或标志位操作。
  • 用于一些算法(如版本控制或并发计数器)中,确保多个线程不冲突地更新共享数据。
示例:

假设有一个变量 x,初始值为 10:

int expected = 10;
int new_value = 20;

if (Compare_and_Swap(&x, expected, new_value)) {
    // 如果 x 的值为 expected (10),就将 x 更新为 new_value (20)
} else {
    // 如果 x 的值不为 expected,CAS 操作失败
}

3. TAS的汇编实现

  • Test-and-Set (TAS) 操作通常可以通过 XCHG 指令(交换)在 x86 汇编中实现。虽然 x86 没有直接的 Test-and-Set 指令,XCHG 被用作实现类似功能的原子操作。
例如,在 x86 中:
; x86 示例,使用 XCHG 实现 Test-and-Set
; 假设锁变量位于 memory_location
; eax 是存储旧值的寄存器
mov eax, 0          ; eax 设为 0,表示“未锁定”
xchg eax, [lock]    ; 将 eax 与锁变量交换,如果锁变量是 0,就设置为 1
                    ; 如果返回的 eax 仍为 0,说明锁成功设置

这个操作会交换 eax[lock] 位置的值,并将原值存储在 eax 中。如果 [lock] 最初是 0eax 将变为 1,并且 lock 会被设置为 1,表示锁已经被占用。

虽然 Test-and-Set 并不一定直接映射到汇编中的特定指令,但它可以通过交换指令来模拟。

4. CAS的汇编实现

  • Compare-and-Swap (CAS) 指令在 x86 中有专门的 CMPXCHG 指令,ARM 等其他架构也有类似的原子操作指令(如 LDREXSTREX)。
例如,在 x86 中:

x86 提供了 CMPXCHG(比较和交换)指令,它就是 CAS 操作的硬件实现。其语法如下:

CMPXCHG destination, source
  • destination:是要比较的内存位置。
  • source:是用于替换的值。
  • 如果 destination 中的值与 source 中的值相等,destination 将被更新为 source 的值,且 AX 寄存器的值将保存 destination 原先的值。
  • 如果不相等,destination 的值保持不变,并且 AX 寄存器将保存 destination 的原值。
; x86 示例,使用 CMPXCHG 实现 Compare-and-Swap
; 假设目标内存位置是 lock,期望值是 eax,新的值是 ebx

mov eax, expected_value    ; 将期望的值加载到 eax
mov ebx, new_value        ; 将新的值加载到 ebx
cmpxchg [lock], ebx        ; 比较 [lock] 的值与 eax(期望值),如果相等则将 [lock] 更新为 ebx(新值)
                        ; 如果不相等,eax 会保存 [lock] 的原值

这个 CMPXCHG 指令会首先将内存中的值与 eax 比较,如果两者相等,它会将 ebx 的值存储到内存中,并且 eax 会保存原来的值;如果两者不相等,内存中的值不会改变,而 eax 会保存当前内存值,指示操作失败。

在 ARM 架构中:

ARM 也提供了类似的原子操作指令,称为 LDREXSTREXLDREX 加载一个值并锁定它,STREX 则用于条件性地写回值。

; ARM 示例,使用 LDREX 和 STREX 实现 CAS
LDREX R0, [lock]    ; 加载锁的当前值到 R0
CMP R0, R1           ; 比较 R0(当前锁的值)与 R1(期望值)
BNE CAS_FAIL         ; 如果不相等,跳转到失败处理
STREX R2, R3, [lock] ; 如果相等,将 R3(新值)写入 [lock],并将成功标志存储在 R2

atomic 封装

std::atomic 是对 Test-and-SetCompare-and-Swap 等底层原子操作的高级封装,使得 C++ 开发者可以在多线程环境中更方便、更安全地使用这些原子操作。

原子操作(std::atomic)有如下特点:

  • 线程安全:原子操作保证了即使在多线程环境下也能正确地修改和读取共享变量。
  • 高效:没有加锁开销,适用于需要频繁检查和修改标志的场景。
  • 原子操作的限制std::atomic 只能用于一些简单的类型(如整数、指针和布尔类型),对于复杂的数据结构,仍然需要其他同步机制(如锁)。

示例:使用 std::atomic<bool> 控制初始化

在控制初始化只执行一次的场景中,你可以使用 std::atomic<bool> 来替代普通的 bool 类型,这样就能确保线程安全地检查和设置初始化标志。

#include <atomic>

std::atomic<bool> isInited(false);

void initFunction() {
    // 原子操作,检查并更新 isInited
    if (!isInited.exchange(true)) { // mutex  std::call_once 和std::once_flag 也可以实现类似功能。
        // 执行初始化逻辑
        // ...
    }
}
  1. std::atomic<bool> isInited(false);:声明一个原子类型的 bool 变量,初始值为 false
  2. isInited.exchange(true):调用 exchange 方法,该方法会将 isInited 的当前值返回,并将其更新为 true。如果当前值为 false,说明还没有初始化,此时会执行初始化逻辑。
  3. 如果 isInited 已经是 true,则 exchange 会返回 true,跳过初始化部分。

避免直接读取 std::atomic 对象, 使用load store

std::atomic<int> counter(0);
counter = 5;  // 这种赋值操作不是原子操作,可能导致线程不安全。
int value = counter;  // 直接读取可能没有同步, 导致缓存不一致问题,多个线程读取的值不一定是最新的。

原子操作是不会失败的

  • load、store 和 exchange 都不会失败,它们保证原子操作的顺序性和正确性。
  • compare_exchange_strong 和 compare_exchange_weak 是可能会“失败”的操作,它们只有在预期值和原子变量的值一致时才会成功执行,否则会返回 false,并更新预期值。

内存顺序:std::memory_order

对于一个线程里的同一个代码块内的无依赖关系的指令,处理器在运行时会重排序乱序执行。这对原子指令也是一样的。但实际生产中,多个原子操作之间,可能会有依赖关系,比如一个线程读取另一个线程写入的数据。为了确保这些依赖关系的正确性,需要使用内存顺序来控制处理器对指令的重排序。

std::atomic 的操作支持指定内存顺序,这控制了编译器和硬件对操作的优化和重排序。常见的内存顺序选项有:

  • std::memory_order_relaxed:没有限制,运行时会乱序。
  • std::memory_order_acquirestd::memory_order_release:分别用于“获取”和“释放”同步,确保操作顺序。
    • std::memory_order_acquire:保证在此操作之前的所有操作不会被重排序到它之后。
    • std::memory_order_release:保证在此操作之后的所有操作不会被重排序到它之前。
  • std::memory_order_seq_cst默认的内存顺序,保证在多线程程序中,所有原子操作按顺序执行,不允许重排序。但性能可能会略受影响。
memory_order_acquire

这两者提供了获取和释放同步的保证:

  • std::memory_order_acquire:保证在此操作之前的所有操作不会被重排序到它之后。
  • std::memory_order_release:保证在此操作之后的所有操作不会被重排序到它之前。
#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter(0);
std::atomic<bool> ready(false);

void producer() {
    counter.store(42, std::memory_order_release);  // 确保 counter 写入在 ready 之前
    ready.store(true, std::memory_order_release);  // 标记准备好
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {}  // 等待 ready 为 true
    std::cout << "Counter: " << counter.load(std::memory_order_acquire) << std::endl;
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    t2.join();

    return 0;
}

解释

  • consumer 线程在 ready.load(std::memory_order_acquire) 后才能读取 counter,确保 ready 被设置为 true 后,counter 的写入才是可见的。
  • memory_order_release 确保 producer 线程的 counter 写操作不会被重排序到 ready.store 之前。
  • memory_order_acquire 确保 consumer 在读取 ready 后,才能正确地读取到 counter 的值。

1. 直接赋值:store

在原子操作中,store 用来将一个值存储到原子变量中。与普通赋值不同,store 会确保在多线程环境下,赋值操作是原子性的(即不会被打断)。

std::atomic<int> counter(0);
counter.store(5, std::memory_order_relaxed);  // 存储 5
- store 会保证在指定的内存顺序下将值存储到原子变量中,因此它是线程安全的。

2. 加载:load

load 用于从原子变量中读取值。它会确保在多线程环境中,读取操作是线程安全的,并且可以指定内存顺序。

int value = counter.load(std::memory_order_acquire);  // 从原子变量读取
  • load 会确保读取的是正确同步后的值,避免在多线程场景下出现读取错误。

3. 比较并交换:compare_exchange_strong

  • compare_exchange_strong(以及 compare_exchange_weak)广泛应用于实现锁和无锁算法。它会尝试将原子变量的值与一个预期值进行比较,如果相同,则将其更新为新值。如果比较失败,原子变量的值不会更改,并且返回 false
compare_exchange_strong 与compare_exchange_weak 的区别
  • compare_exchange_strong 是标准的、严格的 Compare-and-Swap 操作,失败时立即返回,并不会自动重试。
  • compare_exchange_weak 是一种可能会在某些实现中自动重试的版本,适用于高并发场景,特别是在竞争激烈时可以减少操作的开销。
  • 虽然它们有细微的区别,但两者的核心作用是相同的:原子地比较并更新共享变量。在选择使用哪种方法时,主要取决于你对性能的要求以及是否需要在失败时进行重试。
std::atomic<int> value(0);
int expected = 0;
int desired = 42;

if (value.compare_exchange_strong(expected, desired)) {
    std::cout << "CAS succeeded! Value updated to " << value.load() << "\n";
} else {
    std::cout << "CAS failed! Expected: " << expected << ", Actual: " << value.load() << "\n";
}

// 尝试比较并交换
while (!value.compare_exchange_weak(expected, desired)) {
    std::cout << "CAS failed, retrying...\n";
    // 可以执行一些其他的操作或稍微延迟再重试
}
std::cout << "CAS succeeded! Value updated to " << value.load() << "\n";

4. exchange

exchange 是一种常见的原子操作,类似于 store,但它会返回原子变量的先前值。

int oldValue = counter.exchange(5);  // 返回更新前的值,并将 counter 设置为 5
  • exchange 在多线程环境下是安全的,并且可以返回修改前的值,适用于某些需要获取旧值的场景。

常见锁的优缺点和实现

自旋锁(spinlock)

多线程同步的一种忙等待锁,线程反复检查锁变量是否可用。

  • 优点:避免了操作系统进程调度和线程切换,所以自旋锁通常适用在时间比较短的情况下。由于这个原因,操作系统的内核经常使用自旋锁。
  • 缺点:如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度
    • 线程持有锁的时间越长,则持有该锁的线程将被 OS(Operating System) 调度程序中断的风险越大。
  • 解决办法:
    • TicketLock 是采用排队叫号的机制。
    • CLHLock和MCSLock通过链表的方式避免了减少了处理器缓存同步,极大的提高了性能,区别在于CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态。
std::atomic_flag lock = ATOMIC_FLAG_INIT;

void spinlock() {
    while (lock.test_and_set(std::memory_order_acquire)) {
        // 自旋等待,直到锁被释放
    }
    // 临界区代码
    lock.clear(std::memory_order_release);  // 解锁
}

互斥锁(mutex)

把自己阻塞起来(内核态和用户态之间的切换进入阻塞状态,可能上下文切换),等待重新调度请求。

  • 互斥锁的实现
    1. 软件实现:软件互斥锁需要借助操作系统提供的原子操作(如Compare-And-Swap,CAS)来实现 优点是灵活性高 缺点是性能较低,
    2. CAS操作需要三个参数,内存地址A,期望值V,新值N。执行过程如下:
      • 读取内存地址A的原始值,保存在临时变量Value中
      • 比较Value和期待值V是否相等,如果相等则将内存地址A的值更新为新值N
      • 如果内存地址A的值已经被其他线程改变,则不进行更新操作
  • TAS(test and set)
    • 一个TAS指令包括两个子步骤,把给定的内存地址设置为1,然后返回之前的旧值。
    • 硬件实现:硬件互斥锁使用计算机硬件提供的特殊指令(如锁总线指令)来实现。当线程要获取锁时,它会发出一个锁总线指令,这个指令会占用系统总线,使得其他CPU无法读写内存。
      1. 当lock前缀指令执行时,它将锁定处理器总线,确保其他处理器无法同时访问同一内存区域,
代码实例:lock_guard

std::lock_guard<std::mutex> 是 C++ 标准库中的一个 RAII(Resource Acquisition Is Initialization)类,它用于在作用域内自动锁住一个 std::mutex,并在作用域结束时自动解锁。它可以帮助你避免手动管理锁,减少死锁和忘记解锁的风险。

用法

std::lock_guard 通过构造时自动锁住互斥量,并且在作用域结束时自动释放互斥量的锁。因此,你不需要手动调用 mutex.unlock()。只需要确保 lock_guard 的生命周期结束时,互斥量会被自动解锁。

示例代码

#include <iostream>
#include <mutex>
#include <thread>

std::mutex init_mutex_;  // 互斥量,防止多个线程同时初始化

void some_function() {
    // 创建一个 lock_guard 对象,自动锁住 init_mutex_
    std::lock_guard<std::mutex> lock(init_mutex_);

    // 在此作用域内,init_mutex_ 被锁定
    std::cout << "Doing some work inside the critical section...\n";

    // lock_guard 在作用域结束时会自动解锁 mutex
}

int main() {
    std::thread t1(some_function);
    std::thread t2(some_function);

    t1.join();
    t2.join();

    return 0;
}

解释

  1. std::lock_guard<std::mutex> lock(init_mutex_);
  2. 在这行代码中,lock_guard 对象被创建,并立即锁住 init_mutex_
  3. std::lock_guard 的构造函数会调用 mutex.lock() 来锁定互斥量。
  4. lock 变量在作用域结束时自动销毁,这会触发析构函数 lock_guard::~lock_guard(),并自动解锁互斥量(即调用 mutex.unlock())。

  5. some_function 中,互斥量在整个函数内都是被锁定的,因此其他线程无法进入这段代码,避免了并发冲突。

为什么使用 std::lock_guard

  • 避免死锁std::lock_guard 会自动解锁互斥量,减少了忘记解锁的风险。
  • 简洁易懂:它为你管理锁,减少了显式的锁管理代码,使代码更简洁。
  • 线程安全:当多个线程访问同一资源时,std::lock_guard 可以确保每次只有一个线程能够访问临界区,防止数据竞争。

适用场景

通常用于在函数、代码块中保护共享资源。它适合需要访问互斥量的代码块,但不想手动管理锁和解锁时。

代码实例:cv.wait 实现多线程的先后顺序
  1. 使用 std::thread 和 std::mutex + std::condition_variable

如果你希望在销毁环境的 Final 函数中等待某些子线程完成,可以使用 std::mutex 和 std::condition_variable 来同步子线程的完成状态。

示例代码: 假设你有一个主线程(执行 Final 函数)和一个子线程,主线程需要等待子线程完成后才能销毁环境。

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <atomic>

std::mutex mtx;
std::condition_variable cv;
std::atomic<bool> thread_done{false};

void worker_thread() {
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟工作
    std::cout << "Worker thread finished." << std::endl;
    thread_done = true;  // 设置线程完成标志
    cv.notify_one();     // 通知主线程
}

void Final() {
    std::cout << "Final: Waiting for worker thread to finish..." << std::endl;

    // 等待子线程完成
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return thread_done.load(); });
    // cv.wait(lock, [this] { return thread_done.load(); }); // in class

    std::cout << "Final: Worker thread finished. Proceeding with cleanup." << std::endl;
}

int main() {
    // 启动子线程
    std::thread t(worker_thread);

    // 在主线程中调用 Final
    Final();

    t.join();  // 等待子线程结束

    return 0;
}

关键点:

  • std::mutex mtx: 用于锁住条件变量,确保线程间的同步。
  • std::condition_variable cv: 用于实现主线程等待子线程的完成。
  • std::atomic<bool> thread_done: 用于标记子线程是否完成。
  • cv.wait(lock, condition): 主线程会等待子线程设置 thread_done 为 true,然后才会继续执行。

读写锁(ReadWrite Lock)

  • 在读操作和写操作之间提供了更细粒度的同步控制。
  • 多个线程可以同时获取读锁,但只有一个线程能够获取写锁。
    • 读写锁有三种状态:读加锁状态、写加锁状态和不加锁状态
    • 规则
    • 当读写锁在写加锁模式下,任何试图对这个锁进行加锁的线程都会被阻塞,直到写进程对其解锁。
    • 当读写锁在读加锁模式先,任何线程都可以对其进行读加锁操作,但是所有试图进行写加锁操作的线程都会被阻塞,直到所有的读线程都解锁。
  • 缺点:当读者源源不断到来的时候,写者总是得不到读写锁,就会造成不公平的状态。
    • 避免方法: 当处于读模式的读写锁接收到一个试图对其进行写模式加锁操作时,便会阻塞后面对其进行读模式加锁操作的线程。这样等到已经加读模式的锁解锁后,写进程能够访问此锁保护的资源。
  • 优点:
    • 读写锁可以提高并发性,允许多个线程同时读取数据,而只有在需要修改数据时才会互斥。
    • 适合对数据结构读的次数远远大于写的情况。

RCU(Read-Copy-Update)

  • 对读写锁的一种改进。适用于读多写少场景的数据同步机制。
  • 具体内容
    • 并发读取数据不再需要加锁
    • 写数据时,RCU机制通过创建一个副本来实现读写分离,确保在更新过程中没有线程正在读取旧的数据。
      • 写者修改数据前首先拷贝一个被修改元素的副本,然后在副本上进行修改,修改完毕后它向垃圾回收器注册一个回调函数以便在适当的时机执行真正的修改操作。
      • 读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。
      • 有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。

通知与同步

适用于大段的修改和同步。

eventfd 事件通知

eventfd 是 Linux 提供的一种用于线程安全的事件通知机制,类似于文件描述符,可以通过读写操作来实现跨线程或跨进程的同步。通过 eventfd,线程或进程可以通过某些事件(例如计数器增减、通知等)来触发其他线程或进程的动作。

eventfd 可以通过两种方式工作:

  • 计数器模式:通过 eventfd_write 增加计数器,其他线程或进程可以通过 eventfd_read 来读取这些计数器的值。
  • 通知模式:使用 eventfd 进行事件通知,线程或进程通过 eventfd_read 来等待并响应这些事件。

eventfd_read 的功能 : eventfd_read 是一个用于从 eventfd 文件描述符读取事件的系统调用。它会从 eventfd 中读取一个 64 位无符号整数,并返回这个值。这个值通常表示某个事件的状态或计数器的值。

线程间同步

如果两个线程同时对同一个 eventfd 文件描述符进行读写操作:

  • eventfd_read 会阻塞直到 eventfd 中有事件(即计数器的值大于 0),并且在读取之后会减少计数器。
  • eventfd_write 会增加计数器的值,并通知正在等待的线程。

函数原型

#include <sys/eventfd.h>

ssize_t eventfd_read(int efd, uint64_t *value);
ssize_t eventfd_write(int efd, uint64_t value);
  • efdeventfd 文件描述符。
  • * value:指向一个 uint64_t 类型的变量,用于存储从 eventfd 读取的值。
  • value:要写入 eventfd 的值,通常是一个 64 位无符号整数 (uint64_t),表示事件计数器的增加量或特定事件的通知值。

返回值

  • 如果成功,eventfd_read 返回读取到的字节数,通常为 8(因为它读取一个 64 位的值)。
  • 如果失败,返回 -1,并将 errno 设置为合适的错误码。

错误码

  • EAGAINeventfd 中没有事件可读(即没有数据)。
  • EBADF:无效的文件描述符。
  • 其他常见的文件描述符错误。

代码示例

假设你使用 eventfd 来同步不同的线程:

#include <sys/eventfd.h>
#include <unistd.h>
#include <iostream>
#include <cstring>

int main() {
    // 创建 eventfd 文件描述符
    int efd = eventfd(0, EFD_NONBLOCK);
    if (efd == -1) {
        std::cerr << "Failed to create eventfd: " << strerror(errno) << std::endl;
        return -1;
    }

    // 写入事件
    uint64_t u = 1; // 写入一个事件值
    ssize_t s = write(efd, &u, sizeof(u));
    if (s == -1) {
        std::cerr << "Failed to write to eventfd: " << strerror(errno) << std::endl;
        close(efd);
        return -1;
    }

    // 读取事件
    uint64_t read_value;
    s = eventfd_read(efd, &read_value);
    if (s == -1) {
        std::cerr << "Failed to read from eventfd: " << strerror(errno) << std::endl;
    } else {
        std::cout << "Read value: " << read_value << std::endl;
    }

    close(efd);
    return 0;
}

概念题

RedStar (小红书) 笔试:图中有依赖的任务的,需要几个信号量来实现同步

CSDN,有一条依赖线,需要一个信号量

在使用信号量(Semaphore)进行线程同步时,P(proberen)和V(verhogen)操作是非常重要的概念。

  1. P操作(也称为Wait操作或Down操作):

  2. 表示获取或等待信号量。

  3. 如果信号量内部计数值大于0,获取信号量并将计数值减1。
  4. 如果计数值等于0,线程将等待,直到计数值大于0。如果信号量的值大于0,表示资源可用,进程可以继续执行。如果信号量的值为0,表示资源不可用,P操作将阻塞(即等待)进程,直到该信号量的值大于0为止。

伪代码表示为:

P(S):
  while S <= 0:
    // 等待,直到S大于0
  S = S - 1
  1. V操作(也称为Signal操作或Up操作):

  2. 表示释放或增加信号量。

  3. 将信号量内部计数值加1。
  4. 如果存在等待线程,唤醒其中一个线程继续执行。

伪代码表示为:

V(S):
  S = S + 1

P和V操作保证了对共享资源的互斥访问。

一个线程使用P操作等待获取信号量,V操作在使用完共享资源后释放信号量。

信号量的值通常用于控制共享资源的数量,它可以是非负整数。当信号量被初始化为1时,称为二进制信号量(Binary Semaphore),因为它只能取0或1的值,通常用于实现互斥访问临界区。如果信号量的值大于1,称为计数信号量,可用于限制对资源的并发访问数。

在实际编程中,P操作和V操作通常是原子操作,确保在多线程或多进程环境下的正确同步和竞争条件的安全处理。

TP-link笔试:设计的程序在多个CPU上运行时,不应使用哪个实现多个CPU间的数据访问同步?

参考文献

https://www.cswiki.top/pages/f398f1/#blocking-i-o

原文链接:https://blog.csdn.net/qq_15437629/article/details/79116590

https://zhuanlan.zhihu.com/p/161936748

LLVM-MCA: docs

Introduction

LLVM Machine Code Analyzer 是一种性能分析工具,它使用llvm中可用的信息(如调度模型)静态测量特定CPU中机器代码的性能。

性能是根据吞吐量处理器资源消耗来衡量的。该工具目前适用于在后端中使用LLVM调度模型的处理器。

该工具的主要目标不仅是预测代码在目标上运行时的性能,还帮助诊断潜在的性能问题。

给定汇编代码,llvm-mca可以估计每个周期的指令数(IPC)以及硬件资源压力。分析和报告风格的灵感来自英特尔的IACA工具。

github

https://github.com/llvm/llvm-project/tree/main/llvm/tools/llvm-mca

docs

https://llvm.org/docs/CommandGuide/llvm-mca.html

options

architecture

-mtriple=<target triple>
    eg. -mtriple=x86_64-unknown-unknown
-march=<arch>
    Specify the architecture for which to analyze the code. It defaults to the host default target.
-march=<arch>
    Specify the architecture for which to analyze the code. It defaults to the host default target.
# 查看支持的arch
llc --version
# 查看具体支持的CPU Architecture
llc -march=x86 -mattr=help

output-report

-output-asm-variant=<variant id>
    为工具生成的报告指定输出程序集变量。???
-print-imm-hex
    优先16进制输出。
-json
    除了瓶颈分析,基本都支持json格式输出视图
-timeline
    打印指令流水线情况

runtime options

-dispatch=<width>
    为处理器指定不同的调度宽度。调度宽度默认为处理器调度模型中的“IssueWidth”字段。
-register-file-size=<size>
    指定寄存器文件的大小。指定时,该项会限制可用于寄存器重命名的物理寄存器的数量。此标志的值为零意味着“无限数量的物理寄存器”。
-iterations=<number of iterations>
    指定要运行的迭代次数。如果此标志设置为 0,则该工具会将迭代次数设置为默认值(即 100)。
-noalias=<bool>
    loads and stores don’t alias
-lqueue=<load queue size>
-squeue=<store queue size>
    在工具模拟的加载/存储单元中指定加载队列的大小。默认情况下,该工具假定加载队列中的条目数量不受限制。此标志的零值将被忽略,而是使用默认加载队列大小。
-disable-cb
    强制使用通用的 CustomBehaviour 和 InstrPostProcess 类,而不是使用目标特定的实现。通用类从不检测任何自定义危险或对指令进行任何后处理修改。

more values/Info

-resource-pressure
    Enable the resource pressure view. This is enabled by default.
-register-file-stats
    启用注册文件使用统计。
-dispatch-stats
-scheduler-stats
-retire-stats
-instruction-info
    启用额外的调度/发出/retire control unit统计。该视图收集和分析指令分派事件,以及静态/动态分派停顿事件。默认情况下禁用此视图。
-show-encoding
    打印指令16进制
-all-stats
-all-views
-instruction-tables
    这与资源压力视图不同,因为它不需要模拟代码。相反,它按顺序打印每个指令的资源压力的理论均匀分布。
-bottleneck-analysis
    打印有关影响吞吐量的瓶颈的信息。这种分析可能很昂贵,并且默认情况下是禁用的。瓶颈在摘要视图中突出显示。具有有序后端的处理器目前不支持瓶颈分析。???

实现逻辑

样例分析

quick overview of the performance throughput

Iterations:        300
Instructions:      900
Total Cycles:      610
Total uOps:        900

Dispatch Width:    2
uOps Per Cycle:    1.48
IPC:               1.48
Block RThroughput: 2.0
  1. IPC
  2. 理论最大值是\(\(\frac{OneLoopInstructions}{Block\_RThroughput}=(OneLoopInstructions)*(Block\_Throughput)\)\)
  3. uOps Per Cycle
  4. simulated micro opcodes (uOps)
  5. 每个周期的simulated micro opcodes数
  6. 在不考虑循环依赖的情况下,理论上最大值是\(\(\frac{OneLoopUOps}{Block\_RThroughput}=(OneLoopUOps)*(Block\_Throughput)\)\)
  7. A delta between Dispatch Width and this field is an indicator of a performance issue.
  8. The delta between the Dispatch Width (2.00), and the theoretical maximum uOp throughput (1.50) is an indicator of a performance bottleneck caused by the lack of hardware resources, and the Resource pressure view can help to identify the problematic resource usage.
  9. Dispatch Width
  10. 发射到乱序后端的最大微指令操作数(the maximum number of micro opcodes/uOps)?
  11. Block RThroughput (Block Reciprocal Throughput)
  12. 在不考虑循环依赖的情况下,理论上的每次循环的最大block或者iterations数
  13. 受限于dispatch rate和the availability of hardware resources.

Instruction info view

Instruction Info:
[1]: #uOps
[2]: Latency
[3]: RThroughput
[4]: MayLoad
[5]: MayStore
[6]: HasSideEffects (U)

[1]    [2]    [3]    [4]    [5]    [6]    Instructions:
 1      2     1.00                        vmulps      %xmm0, %xmm1, %xmm2
 1      3     1.00                        vhaddps     %xmm2, %xmm2, %xmm3
 1      3     1.00                        vhaddps     %xmm3, %xmm3, %xmm4
 ```

显示了指令里队列每条指令的**延迟**和**吞吐量的倒数**。

RThroughput是指令吞吐量的倒数。在不考虑循环依赖的情况下,吞吐量是**单周期能执行的同类型指令的最大数量**。

### Resource pressure view
Resources: [0] - JALU0 [1] - JALU1 [2] - JDiv [3] - JFPA [4] - JFPM [5] - JFPU0 [6] - JFPU1 [7] - JLAGU [8] - JMul [9] - JSAGU [10] - JSTC [11] - JVALU0 [12] - JVALU1 [13] - JVIMUL

Resource pressure per iteration: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [13] - - - 2.00 1.00 2.00 1.00 - - - - - - -

Resource pressure by instruction: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [13] Instructions: - - - - 1.00 - 1.00 - - - - - - - vmulps %xmm0, %xmm1, %xmm2 - - - 1.00 - 1.00 - - - - - - - - vhaddps %xmm2, %xmm2, %xmm3 - - - 1.00 - 1.00 - - - - - - - - vhaddps %xmm3, %xmm3, %xmm4 ```

每次循环或者每条指令执行,消耗的资源周期数。从而找到高资源占用的部分。

Timeline View

可打印流水线情况

Timeline view:
                    012345
Index     0123456789

[0,0]     DeeER.    .    .   vmulps   %xmm0, %xmm1, %xmm2
[0,1]     D==eeeER  .    .   vhaddps  %xmm2, %xmm2, %xmm3
[0,2]     .D====eeeER    .   vhaddps  %xmm3, %xmm3, %xmm4
[1,0]     .DeeE-----R    .   vmulps   %xmm0, %xmm1, %xmm2
[1,1]     . D=eeeE---R   .   vhaddps  %xmm2, %xmm2, %xmm3
[1,2]     . D====eeeER   .   vhaddps  %xmm3, %xmm3, %xmm4
[2,0]     .  DeeE-----R  .   vmulps   %xmm0, %xmm1, %xmm2
[2,1]     .  D====eeeER  .   vhaddps  %xmm2, %xmm2, %xmm3
[2,2]     .   D======eeeER   vhaddps  %xmm3, %xmm3, %xmm4


Average Wait times (based on the timeline view):
[0]: Executions
[1]: Average time spent waiting in a scheduler's queue
[2]: Average time spent waiting in a scheduler's queue while ready
[3]: Average time elapsed from WB until retire stage

      [0]    [1]    [2]    [3]
0.     3     1.0    1.0    3.3       vmulps   %xmm0, %xmm1, %xmm2
1.     3     3.3    0.7    1.0       vhaddps  %xmm2, %xmm2, %xmm3
2.     3     5.7    0.0    0.0       vhaddps  %xmm3, %xmm3, %xmm4
       3     3.3    0.5    1.4       <total>

影响因素包括:

  1. 数据冲突/依赖:读后写,写后读依赖 。无法指令级并行,也可以通过寄存器重命名解决
  2. 结构冲突:占用发射位 或者 同一硬件
  3. 控制冲突:分支?
  4. instructions must retire in program order, so [1,0] has to wait for [0,2] to be retired first

Bottleneck Analysis

  • 可以分析出数据冲突/依赖和结构冲突的影响大小
  • 准确性取决于模拟和是否有对应CPU模型。
  • 暂时不支持有序后端。
Cycles with backend pressure increase [ 91.52% ]
Throughput Bottlenecks:
  Resource Pressure       [ 0.01% ]
  - SBPort0  [ 0.01% ]
  - SBPort1  [ 0.01% ]
  - SBPort5  [ 0.01% ]
  Data Dependencies:      [ 91.51% ]
  - Register Dependencies [ 91.51% ]
  - Memory Dependencies   [ 10.76% ]
  • 端口信息来自TableGen llvm/lib/Target/X86/X86SchedSandyBridge.td
  • 鲲鹏920的来自 llvm/lib/Target/AArch64/AArch64SchedTSV110.td

额外信息

  1. Dynamic Dispatch Stall Cycles
  2. Dispatch Logic
  3. 可以看出流水线发射满带宽或几条指令的时间占比
  4. Schedulers
  5. 每个周期微指令发射数占比
  6. Scheduler's queue usage
  7. 执行时使用的平均或最大buffer entries (i.e., scheduler queue entries)
  8. AMD Jaguar

  9. JALU01 - A scheduler for ALU instructions. JFPU01 - A scheduler floating point operations. JLSAGU - A scheduler for address generation.

  10. Retire Control Unit

  11. 在一个周期里有多少指令retired的占比(好吧,感觉有语病)
  12. A re-order buffer (ROB) 的使用情况
  13. Register File statistics
  14. physical register file (PRF)
  15. floating-point registers (JFpuPRF)
  16. integer registers (JIntegerPRF)

Instruction Flow

llvm-mca 假设指令在模拟开始之前已经全部解码并放入队列中。因此,指令提取和解码阶段没有被计算。未考虑前端的性能瓶颈。此外,llvm-mca 不模拟分支预测。

Instruction Dispatch

处理器的默认 dispatch width值等于LLVM’s scheduling model里的IssueWidth值。

An instruction can be dispatched if:

  • The size of the dispatch group is smaller than processor’s dispatch width.
  • There are enough entries in the reorder buffer.
  • There are enough physical registers to do register renaming.
  • The schedulers are not full.

reorder buffer负责跟踪命令,使之按照程序顺序retired结束。其默认值为 MicroOpBufferSize 。

各种Buffered resources 被视作scheduler resources.

Instruction Issue

每个处理器调度器实现一个指令缓冲区。指令必须在调度程序的缓冲区中等待,直到输入寄存器操作数可用。只有在那个时候,指令才符合执行的条件,并且可能会被发出(可能是乱序的)以供执行。 llvm-mca 在调度模型的帮助下计算指令延迟。

llvm-mca 的调度器旨在模拟多处理器调度器。调度器负责跟踪数据依赖关系,并动态选择指令消耗哪些处理器资源。它将处理器资源单元和资源组的管理委托给资源管​​理器。资源管理器负责选择指令消耗的资源单元。例如,如果一条指令消耗了一个资源组的1cy,则资源管理器从该组中选择一个可用单元;默认情况下,资源管理器使用循环选择器来保证资源使用在组的所有单元之间均匀分配。

llvm-mca’s scheduler internally groups instructions into three sets:

  • WaitSet: a set of instructions whose operands are not ready.
  • ReadySet: a set of instructions ready to execute.
  • IssuedSet: a set of instructions executing.

Write-Back and Retire Stage

retire control unit

  1. When instructions are executed,the flags the instruction as “ready to retire.”
  2. Instructions are retired in program order
  3. free the physical registers

Load/Store Unit and Memory Consistency Model

load/store unit (LSUnit)用来模拟乱序memory操作

The rules are:

  1. A younger load is allowed to pass an older load only if there are no intervening stores or barriers between the two loads.
  2. A younger load is allowed to pass an older store provided that the load does not alias with the store.
  3. A younger store is not allowed to pass an older store.不能交换顺序的意思
  4. A younger store is not allowed to pass an older load.

假设 loads do not alias (-noalias=true) store operations.Under this assumption, younger loads are always allowed to pass older stores. ???

LSUnit不打算跑alias analysis来预测何时load与store不相互alias???

in the case of write-combining memory, rule 3 could be relaxed to allow reordering of non-aliasing store operations.???

LSUnit不管的其余三点:

  1. The LSUnit does not know when store-to-load forwarding may occur.
  2. The LSUnit does not know anything about cache hierarchy and memory types.
  3. The LSUnit does not know how to identify serializing operations and memory fences.
  4. The LSUnit does not attempt to predict if a load or store hits or misses the L1 cache(不考虑cache命中,默认是命中L1,产生the load-to-use latency的最乐观开销)

llvm-mca 不知道序列化操作或内存屏障之类的指令。 LSUnit 保守地假设同时具有“MayLoad”和未建模副作用的指令的行为类似于“软”load-barrier。这意味着,它在不强制刷新load队列的情况下序列化加载。类似地,“MayStore”和具有未建模副作用的指令被视为store障碍。完整的memory-barrier是具有未建模副作用的“MayLoad”和“MayStore”指令。LLVM的实现是不准确的,但这是我们目前使用 LLVM 中可用的当前信息所能做的最好的事情。

load/store barrier会占用在load/store 队列里占用一项。 当load/store barrier是其队列里oldest项时,其会被执行

In-order Issue and Execute

有序处理器被建模为单个 InOrderIssueStage 阶段。它绕过 Dispatch、Scheduler 和 Load/Store 单元。一旦它们的操作数寄存器可用并且满足资源要求,就会发出指令。根据LLVM的调度模型中IssueWidth参数的值,可以在一个周期内发出多条指令。一旦发出,指令就会被移到 IssuedInst 集,直到它准备好retire。 llvm-mca 确保按顺序提交写入。但是,如果 RetireOOO 属性for at least one of its writes为真,则允许指令提交写入并无序retire???

Custom Behaviour 自定义行为

某些指令在该模型中并不能被准确的模拟。为了几条指令而修改模型不是个好的选择,一般通过CustomBehaviour类对某些指令进行特殊建模:自定义数据依赖,以及规避、单独处理特殊情况。

为此,llvm-mca设置了一个通用的以及多个特殊的CustomBehaviour类。下面两种情况下会使用通用类:

  1. 开启了-disable-cb选项
  2. 不存在针对某目标的特殊类(通用类也做不了什么,我什么也做不到😥)

但是注意目前只有in-order流水线实现了CustomBehaviour类,out-order流水线将来也会支持。

该类主要通过checkCustomHazard()函数来实现,通过当前指令和真正流水线中执行的指令,来判断当前指令需要等待几个周期才能发射。

如果想对没有实现的目标添加CustomBehaviour类,可以参考已有的实现,比如在/llvm/lib/Target/AMDGPU/MCA/目录下。

Custom Views 自定义视图

关于自定义的视图的添加路径,如果没有输出从未在MC layer classes (MCSubtargetInfo, MCInstrInfo, etc.)里出现过的新后端值,请把实现加入/tools/llvm-mca/View/。相反,请加入/lib/Target/<TargetName>/MCA/目录。

关于Custom Views所需内容,需要写特殊的CustomBehaviour类来覆写CustomBehaviour::getViews()函数,根据位置的不同还有三种实现getStartViews(), getPostInstrInfoViews(),getEndViews()

影响准确性的因素

调度模型不仅用于计算指令延迟和吞吐量,还用于了解可用的处理器资源以及如何模拟它们。

llvm mca进行分析的质量不可避免地受到llvm中调度模型质量的影响。

功能(能估计的值

  1. IPC
  2. 硬件资源压力resource-pressure
  3. 一些额外Info? 1.

    register-file-stats
    -dispatch-stats
    -scheduler-stats
    -retire-stats
    -instruction-info
    instruction-tables
    
  4. 吞吐量瓶颈?

支持对特定代码块的分析

  1. 汇编代码,支持命名和嵌套

    # LLVM-MCA-BEGIN block-name
    add %eax, %eax
    # LLVM-MCA-END
    
  2. 高级语言,通过内联汇编实现

 int foo(int a, int b) {
     __asm volatile("# LLVM-MCA-BEGIN foo");
     a += 42;
     __asm volatile("# LLVM-MCA-END");
     a *= b;
     return a;
 }

但是,这会干扰循环矢量化等优化,并可能对生成的代码产生影响。具体影响请对比汇编代码。

相关论文

Google学术搜llvm-mca,一堆论文。但是不急着看,因为没有预备知识,没有问题的去看论文。效率和收获很低的,而且会看不懂。

相关项目

mc-ruler

mc-ruler是整合了llvm-mca的cmake,可以打印指定部分的代码分析信息。如果之后要测试可能用得上。

需要进一步的研究学习

  1. 具体功能
  2. llvm如何实现的,要看代码。

遇到的问题

  1. (llvm-mca detects Intel syntax by the presence of an .intel_syntax directive at the beginning of the input. By default its output syntax matches that of its input.)
  2. ???的地方
  3. 大概看了一下功能,但是性能怎么对比呢。准确值是多少呢?
  4. arm kunpeng pmu-tools 实现
  5. 每次的估计值会波动吗?

如何和大神交流呢+提问的艺术

开题缘由、总结、反思、吐槽~~

参考文献

llvm Backend

llvm 后端 概述

  • 后端(backend)由一套分析转换Pass组成,它们的任务是代码生成,即将LLVM中间表示(IR)变换为目标代码(或者汇编)。
  • LLVM支持广泛的目标:ARM,AArch64,Hexagon,MSP430,MIPS,Nvidia PTX,PowerPC,R600,SPARC,SystemZ,X86,和XCore。
  • 所有这些后端共享一套共用的接口,它是目标无关代码生成器的一部分,以通用API的方法抽象化后端任务。每个目标必须特殊化代码生成通用类,以实现目标特定的行为。

后端的基本流程

  • 下图给出了将LLVM IR转换为目标汇编代码必需的步骤

Backend

  • 浅灰色的中间框,也叫super pass,是必需的,它们是后端的核心部分。
  • 白色框所指示的,可以执行非必需的优化Pass以进一步改进翻译的质量。对于提高所生成的代码的效率更重要。
  • 比如-O3的优化,而且其顺序对结果也有影响。

详细解释各阶段:

  1. 指令选择(Instruction Selection):将三地址结构的LLVM IR变换为DAG(Directed Acyclic Graph)
  2. 每个DAG能够表示单一基本块的计算。DAG内节点表示机器指令,边表示数据依赖关系。
  3. 第1次指令调度(Instruction Scheduling),也称为前寄存器分配(RA)调度,对指令排序,同时尝试发现尽可能多的指令层次的并行。然后这些指令被变换为MachineInstr三地址表示。
  4. 寄存器分配(Register Allocation),它将无限的虚拟寄存器的引用转换为有限的目标特定的寄存器集,寄存器不够时挤出(spill)到内存。
  5. 第2次指令调度,也称为后寄存器分配(RA)调度。因为此时在这个点可获得真实的寄存器信息,某些类型寄存器存在额外的风险和延迟,它们可被用以改进指令顺序。
  6. 代码输出(Code Emission)阶段将指令从MachineInstr表示变换为MCInst实例。这种新的表示更适合汇编器和链接器,它有两种选择:输出汇编代码或者输出二进制块(blob)到一种特定的目标代码格式。

后端代码结构

后端的实现分散在LLVM源代码树的不同目录中。代码生成背后的主要程序库位于lib目录和它的子文件夹CodeGen、MC、TableGen、和Target中, 具体参考文档

Tablegen位置在类似 llvm/lib/Target/X86/X86.td的地方

llvm 编译优化

  • 通过llvm的分析和转换Pass相结合实现的。
  • 首先,通过分析Pass获取程序的一些特性和数据流等信息,例如控制流分析、数据流分析、依赖分析等。
  • 然后,根据所得到的分析信息,llvm会执行转换Pass,对程序进行一系列的重构、优化和变换,例如常量传播、死代码消除、内联函数、循环展开等。

举例:O3优化实现

程序优化选项 -O3 是通过启用 LLVM Pass Manager 并按照顺序执行包含多个具体优化 Pass 的过程实现的。包括:

  1. 函数内部优化 Pass,如内联、函数内联、无用函数清理、控制流扁平化;
  2. 函数间优化 Pass,如基于静态单走边分析的间接调用目标推导、函数每次调用的参数的重复计算消除、通过符号解析执行的函数简介化等。
  3. 模块优化 Pass,如死代码消除、全局优化、常量传播、数值宽化和窄化、整除优化等;
  4. 特定于架构的优化 Pass,包括指令调度和寄存器分配等。

这些 Pass 的执行范围涵盖 LLVM IR 与 LLVM 后端。

TableGen

  • LLVM的TableGen是一种表格驱动代码生成工具,主要用于生成汇编器、反汇编器、指令选择器、调度器等代码。
  • 它使用基于LLVM IR的DSL(Domain-Specific Language)来描述目标指令集的特性和规则,然后将这些信息转换为C++代码。
  • 使用TableGen可以将目标指令集的实现与源代码分离,从而提高代码的可读性和可维护性。

TableGen的输入文件使用扩展名“.td”(TableGen的缩写),它们可以描述如下内容:

  1. Instruction Set Architecture (ISA) - 描述目标机的指令集特性,例如指令集架构、寄存器、寄存器类、操作数类型、地址模型、端对齐性等。
  2. Selection DAG - 描述了如何将LLVM IR节点映射到目标机指令集的指令,例如指令的操作码、操作数、调用约定、指令延迟等。
  3. Pattern Matching - 对匹配到的指令模板做出生成想要的IR节点的选择。
  4. Instruction Scheduling - 描述调度器行为、指令之间的时间关系,以及如何将指令插入到调度图中的规则等。

  5. TableGen自动化了目标机指令集的大部分工作,同时也使得自定义目标机变得相对容易。

  6. 该工具支持针对多种平台和编译器的后端代码生成。
  7. 对于嵌入式系统和非标准指令集架构等领域,TableGen有着广泛的应用。

相关的概念

  • 目标描述语言(Target Description Language,TDL)来定义目标架构特定的指令和寄存器。其中,TDL可用于目标架构中指令定义和寄存器定义的映射关系和动态生成机器指令的规则。

实践:llvm IR 后端

实现一个简单的LLVM IR后端,将LLVM IR转换为x86汇编代码,能line by line的输出。

参考LLVM官方文档中的“Writing an LLVM Backend”以及“TableGen Backends”

需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

https://getting-started-with-llvm-core-libraries-zh-cn.readthedocs.io/zh_CN/latest/ch06.html#id2

llvm Pass

简介

  • Pass就是“遍历一遍IR,可以同时对它做一些操作”的意思。翻译成中文应该叫“传递”。
  • 在实现上,LLVM的核心库中会给你一些 Pass类 去继承。你需要实现它的一些方法。
  • ModulePass , CallGraphSCCPass, FunctionPass , or LoopPass, or RegionPass
  • 最后使用LLVM的编译器会把它翻译得到的IR传入Pass里,给你遍历和修改。

作用

  1. 插桩: 在Pass遍历LLVM IR的同时,自然就可以往里面插入新的代码。
  2. 机器无关的代码优化:编译原理一课说:IR在被翻译成机器码前会做一些机器无关的优化。 但是不同的优化方法之间需要解耦,所以自然要各自遍历一遍IR,实现成了一个个LLVM Pass。 最终,基于LLVM的编译器会在前端生成LLVM IR后调用一些LLVM Pass做机器无关优化, 然后再调用LLVM后端生成目标平台代码。
  3. 静态分析: 像VSCode的C/C++插件就会用LLVM Pass来分析代码,提示可能的错误 (无用的变量、无法到达的代码等等)。

理解 llvm Pass

理解Pass API

Pass类是实现优化的主要资源。然而,我们从不直接使用它,而是通过清楚的子类使用它。当实现一个Pass时,你应该选择适合你的Pass的最佳粒度,适合此粒度的最佳子类,例如基于函数、模块、循环、强联通区域,等等。常见的这些子类如下:

  • ModulePass:这是最通用的Pass;它一次分析整个模块,函数的次序不确定。它不限定使用者的行为,允许删除函数和其它修改。为了使用它,你需要写一个类继承ModulePass,并重载runOnModule()方法。
  • FunctionPass:这个子类允许一次处理一个函数,处理函数的次序不确定。这是应用最多的Pass类型。它禁止修改外部函数、删除函数、删除全局变量。为了使用它,需要写一个它的子类,重载runOnFunction()方法。
  • BasicBlockPass:这个类的粒度是基本块。FunctionPass类禁止的修改在这里也是禁止的。它还禁止修改或者删除外部基本块。使用者需要写一个类继承BasicBlockPass,并重载它的runOnBasicBlock()方法。

被重载的入口函数runOnModule()、runOnFunction()、runOnBasicBlock()返回布尔值false,如果被分析的单元(模块、函数和基本块)保持不变,否则返回布尔值true。

Pass的执行顺序/依赖

  • ChatGPT说默认顺序是:FunctionPass -> Module Pass -> LoopPass ?
  • 当然我们是可以修改插入Pass的执行顺序的。
char PIMProf::AnnotationInjection::ID = 0;
// 注册 llvm pass
static RegisterPass<PIMProf::AnnotationInjection> RegisterMyPass(
    "AnnotationInjection", "Inject annotators to uniquely identify each basic block.");

static void loadPass(const PassManagerBuilder &,
                           legacy::PassManagerBase &PM) {
    PM.add(new PIMProf::AnnotationInjection());
}

// Ox 的代码 llvm pass 在EP_OptimizerLast 位置load
static RegisterStandardPasses clangtoolLoader_Ox(PassManagerBuilder::EP_OptimizerLast, loadPass);
// O0 的代码 llvm pass EP_EnabledOnOptLevel0 位置load
static RegisterStandardPasses clangtoolLoader_O0(PassManagerBuilder::EP_EnabledOnOptLevel0, loadPass);

流程

  1. 编写LLVM pass代码
  2. 配置编译环境(cmake or make)
  3. 运行(opt or clang)

1 代码框架

最简单框架hello.cpp如下,注意Important一定需要:

#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/Support/raw_ostream.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/Transforms/IPO/PassManagerBuilder.h"

using namespace llvm;

namespace {
 // Important
  struct Hello : public FunctionPass {
    static char ID;
    Hello() : FunctionPass(ID) {}
 // Important
    bool runOnFunction(Function &F) override {
      errs() << "Hello: ";
      errs().write_escaped(F.getName()) << '\n';
      return false;
    }
  };
}

char Hello::ID = 0;

// Important:Register for opt
static RegisterPass<Hello> X("hello", "Hello World Pass");

// Important:Register for clang
static RegisterStandardPasses Y(PassManagerBuilder::EP_EarlyAsPossible,
  [](const PassManagerBuilder &Builder, legacy::PassManagerBase &PM) {
    PM.add(new Hello());
  });

2 编译动态库

使用cmake

参考官方文档

An example of a project layout is provided below.

<project dir>/
    |
    CMakeLists.txt
    <pass name>/
        |
        CMakeLists.txt
        Pass.cpp
        ...

Contents of <project dir>/CMakeLists.txt:

find_package(LLVM REQUIRED CONFIG)

separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS})
add_definitions(${LLVM_DEFINITIONS_LIST})
include_directories(${LLVM_INCLUDE_DIRS})

add_subdirectory(<pass name>)

Contents of <project dir>/<pass name>/CMakeLists.txt:

add_library(LLVMPassname MODULE Pass.cpp)

运行cmake编译。产生LLVMPassname.so文件

mkdir build && cd build
cmake .. && make

使用命令行

请阅读知乎的文章

3 使用

opt加载Pass

clang -c -emit-llvm main.c -o main.bc # 随意写一个C代码并编译到bc格式
opt -load path/to/LLVMHello.so -hello main.bc -o /dev/null

把源代码编译成IR代码,然后用opt运行Pass实在麻烦且无趣。

clang加载Pass

clang -Xclang -load -Xclang path/to/LLVMHello.so main.c -o main
# or
clang++ -Xclang -load -Xclang ./build/hello/libLLVMPassname.so test.cpp -o main

实践

插入代码

void InjectSimMagic2(Module &M, Instruction *insertPt, uint64_t arg0, uint64_t arg1, uint64_t arg2)
{
    LLVMContext &ctx = M.getContext();
    std::vector<Type *> argtype {
        Type::getInt64Ty(ctx), Type::getInt64Ty(ctx), Type::getInt64Ty(ctx)
    };
    FunctionType *ty = FunctionType::get(
        Type::getVoidTy(ctx), argtype, false
    );
    // template of Sniper's SimMagic0
    InlineAsm *ia = InlineAsm::get(
        ty,
        "\tmov $0, %rax \n"
        "\tmov $1, %rbx \n"
        "\tmov $2, %rcx \n"
        "\txchg %bx, %bx\n",
        "imr,imr,imr,~{rax},~{rbx},~{rcx},~{dirflag},~{fpsr},~{flags}",
        true
    );
    Value *val0 = ConstantInt::get(IntegerType::get(ctx, 64), arg0);
    Value *val1 = ConstantInt::get(IntegerType::get(ctx, 64), arg1);
    Value *val2 = ConstantInt::get(IntegerType::get(ctx, 64), arg2);
    std::vector<Value *> arglist {val0, val1, val2};
    CallInst::Create(
            ia, arglist, "", insertPt);
}

这段代码使用内联汇编嵌入到 LLVM IR 中,指令如下:

mov $0, %rax
mov $1, %rbx
mov $2, %rcx
xchg %bx, %bx

其中:

  • mov $0, %rax 将立即数 arg0 装载到通用寄存器 %rax 中。
  • mov $1, %rbx 将立即数 arg1 装载到通用寄存器 %rbx 中。
  • mov $2, %rcx 将立即数 arg2 装载到通用寄存器 %rcx 中。
  • xchg %bx, %bx 是一条无操作指令,用于保证该汇编代码的原子性。

打印每个BBL内的汇编指令

由于直接打印的是llvm IR的表示,想要打印特定架构比如x86的汇编代码,其实需要进行llvm后端的转换。(取巧,可执行文件反汇编,然后根据插入的汇编桩划分)


需要进一步的研究学习

暂无

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

复现PIMProf论文时,用到了使用 llvm pass来插入特殊汇编

参考文献

https://www.llvm.org/docs/WritingAnLLVMPass.html

https://zhuanlan.zhihu.com/p/122522485

GNU Assembly File

GNU汇编语法

伪指令

  • 指示(Directives): 以点号开始,用来指示对编译器,连接器,调试器有用的结构信息。指示本身不是汇编指令。
伪指令 描述
.file 指定由哪个源文件生成的汇编代码。
.data 表示数据段(section)的开始地址
.text 指定下面的指令属于代码段。
.string 表示数据段中的字符串常量。
.globl main 指明标签main是一个可以在其它模块的代码中被访问的全局符号 。
.align 数据对齐指令
.section 段标记
.type 设置一个符号的属性值
  • 语法:.type name , description
  • description取值如下:
    • %function 表示该符号用来表示一个函数名
    • %object 表示该符号用来表示一个数据对象

至于其它的指示你可以忽略。

实践:阅读汇编文件

从最简单的C文件入手

int main(){
 return 0;
}

运行gcc -S -O3 main.c -o main.s,得到main.s文件

 .file "simple.cpp"
 .text
 .section .text.startup,"ax",@progbits
 .p2align 4
 .globl main
 .type main, @function
main:
.LFB0:
 .cfi_startproc
 endbr64
 xorl %eax, %eax
 ret
 .cfi_endproc
.LFE0:
 .size main, .-main
 .ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
 .section .note.GNU-stack,"",@progbits
 .section .note.gnu.property,"a"
 .align 8
 .long  1f - 0f
 .long  4f - 1f
 .long  5
0:
 .string  "GNU"
1:
 .align 8
 .long  0xc0000002
 .long  3f - 2f
2:
 .long  0x3
3:
 .align 8
4:
  • 下面回答来自ChatGPT-3.5,暂时没有校验其可靠性(看上去貌似说得通)。

section

  • .section .rodata.str1.1,"aMS",@progbits,1
  • rodata.str1.1是一个标号(label), 意思是只读数据段的字符串常量
  • aMS是一个属性值:
    • 可分配的(allocatable),即程序运行时需要动态分配空间才能分配该代码段,
    • 不可执行 (M),
    • 数据的类型为串(S)
    • 其余属性值:对齐方式的通常为 b(byte对齐),w(word对齐),或者其他更大的对齐单位,例如 d(double word对齐)。
  • @progbits: 表示该段的类型是程序数据段(PROGBITS),这种类型的段包含程序的代码和数据。
  • 1: 表示该段的对齐方式是2^1 = 2个字节(按字节对齐)。如果不写这个数字,默认对齐到当前机器的字长。
  • .section .text.startup,"ax",@progbits 其中ax表示该段是可分配的(allocatable)和可执行的(executable)。
  • ".section .note.GNU-stack"指令用于告诉链接器是否允许在堆栈上执行代码。
  • ".section .note.gnu.property"指令用于指定一些属性,这里是一个GNU特性标记。

汇编的入口

  • 汇编的执行流程:入口函数在哪里
  • 入口函数在该文件中的名称为“main”,定义于“.text.startup” section,其首地址为“.globl main”。
 .section .text.startup,"ax",@progbits
 .p2align 4
 .globl main
 .type main, @function

构造函数

  • 为了确保这些初始化操作可以在程序启动时正确执行,编译器将把这些构造函数和析构函数的调用代码打包成若干个函数,统一放在名字为“_GLOBAL__sub_I_xxx”的section中。
  • 因为在C++程序编译后的二进制文件中,全局变量、静态变量和全局对象等信息都需要进行初始化操作,包括构造函数(初始化对象)和析构函数(清理对象)。
  • 在这段汇编代码中,也就是那个"_GLOBAL__sub_I_main"函数,它是C++全局变量和静态变量的构造函数,它调用了预初始化函数 "ios_base::Init()",并注册了一个在程序退出时调用的析构函数 "__cxa_atexit"。
  • 在".init_array" section中,定义了一个"_GLOBAL__sub_I_main"的地址,这是在程序启动时需要调用的所有C++全局和静态对象的初始化函数列表,编译器链接这个列表并在程序启动时依次调用这些初始化函数。
  • 总之,这两个section的存在是为了保证C++全局变量和静态变量的正确初始化。

其中四条指令都定义了一些符号或变量,并分配了一些内存空间,这些在程序里的意义如下:

  1. ".quad _GLOBAL__sub_I_main":

在程序启动时,将调用所有全局静态对象的构造函数。这些构造函数被放在一个名为"_GLOBAL__sub_I_xxx"的section中,而每个section都是由一个指向该section所有对象的地址列表所引用。这里的".quad _GLOBAL__sub_I_main"是为了将"_GLOBAL__sub_I_main"函数的地址添加到该列表中。

  1. ".local _ZStL8__ioinit":

这条指令定义了一个本地符号"_ZStL8__ioinit",它表示C++标准输入输出的初始化过程。由于该符号是一个本地符号,所以只能在编辑该文件的当前单元中使用该符号。

  1. ".comm _ZStL8__ioinit,1,1":

这条指令定义了一个名为"_ZStL8__ioinit"的未初始化的弱符号,并为该符号分配了1个大小的字节空间。这个弱符号定义了一个C++标准输入输出部分的全局状态对象。在全用动态库时,不同的动态库可能有自己的IO状态,所以为了确保C++输入输出的状态正确,需要为其指定一个单独的段来存储这些状态数据。在这里,".comm _ZStL8__ioinit,1,1"将会为"_ZStL8__ioinit"符号分配一个字节大小的空间。

  1. ".hidden __dso_handle":

这条指令定义了一个隐藏的符号 "__dso_handle"。这个符号是一个链接器生成的隐式变量,其定义了一个指向被当前动态库使用的全局数据对象的一个指针。该符号在被链接进来的库中是隐藏的,不会被其他库或者main函数本身调用,但是在main返回后,可以用来检查库是否已经被卸载。

末尾的元数据

这段代码是一些特殊的指令和数据,主要是用于向可执行文件添加一些元数据(metadata)。这些元数据可能包含各种信息,如调试信息、特定平台的指令集支持等等。

具体来说:

  • ".long"指令用于定义一个长整型数值,这里用来计算地址之间的差值。
  • 例如,第一行".long 1f - 0f"建立了一个长整型数值,表示"1:"标签相对于当前指令地址(即0f)的偏移量。偏移量可以用来计算标签对应的指令地址,从而可用于跳转或计算指针偏移量。
  • "4f - 1f",即"4:"标签相对于"1:"标签的偏移量;
  • ".long 0xc0000002"表示这是一个特殊的属性标记,标识这个文件可以在Linux平台上执行。它是用来告诉操作系统这个程序是用特定指令集编译的。
  • ".long 0x3"表示另一个属性标记,表示这个文件可以加载到任意地址。

总之,这些元数据可能对程序运行起到关键作用,但在大多数情况下可能都没有明显的作用,因此看起来没有用。

比较汇编的debugging symbols

执行gcc -S -g testBigExe.cpp -o testDebug.s,对比之前的汇编文件,由72行变成9760行。

  • -g前后

.loc

.LBE32:
 .file 3 "/usr/include/c++/9/bits/char_traits.h"
 .loc 3 342 2 is_stmt 1 view .LVU4
 .loc 1 5 11 is_stmt 0 view .LVU5
  • 第一行:.loc 3 342 2 表示当前指令对应的源代码文件ID为3,在第342行,第2列(其中第1列是行号,第2列是第几个字符),同时is_stmt为1表示这条指令是语句的起始位置。
  • 第二行:.loc 1 5 11 表示当前指令对应的源代码文件ID为1,在第5行,第11列,同时is_stmt为0表示这条指令不是语句的起始位置。
  • view .LVU4 表示当前指令所处的作用域(scope)是.LVU4。作用域是指该指令所在的函数、代码块等一段范围内的所有变量和对象的可见性。在这个例子中,.LVU4 是一个局部变量作用域,因为它是位于一个C++标准库头文件中的一个函数的起始位置。

debug section

新增的这些 section 存储了 DWARF 调试信息。DWARF(Debugging With Attributed Record Formats)是一种调试信息的标准格式,包括代码中的变量、类型、函数、源文件的映射关系,以及代码的编译相关信息等等。

具体来说,这些 section 存储的内容如下:

  • .debug_info:包含程序的调试信息,包括编译单元、类型信息、函数和变量信息等。
  • .debug_abbrev:包含了 .debug_info 中使用到的所有缩写名称及其对应的含义,用于压缩格式和提高效率。
  • .debug_loc:存储每个程序变量或表达式的地址范围及其地址寄存器、表达式规则等信息。在调试时用来确定变量或表达式的值和范围。
  • .debug_aranges:存储简化版本的地址范围描述,允许调试器加速地定位代码和数据的位置。
  • .debug_ranges:存储每个编译单元(CU)的地址范围,每个范围都是一个有限开区间。
  • .debug_line:存储源代码行号信息,包括每行的文件、行号、是否为语句起始位置等信息。
  • .debug_str:包含了所有字符串,如文件名、函数名等,由于每个调试信息的数据都是字符串,因此这是所有调试信息的基础。

需要注意的是,这些 section 中的信息是根据编译器的配置和选项生成的,因此不同编译器可能会生成略有不同的调试信息。

需要进一步的研究学习

  • 在编译的过程中,哪个阶段 label会变成真实执行地址

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

https://www.cnblogs.com/zuofaqi/articles/12853734.html

https://www.cnblogs.com/zuofaqi/articles/12853734.html