VLLM Sourse Code Reading
Basic
1 |
|
Architecture
- LLM: 最上层的类,构造函数中会根据传入的参数构建 EngineArgs 然后创建 LLMEngine 对象。
- LLMEngine: 包含一些组件 InputPreprocessor, ExecutorBase 负责模型推理的最上层的类
- ExecutorBase 会初始化 N 个 WorkerWrapperBase (包装实际的 worker,类比成 GPU)
- Worker: 在 GPU 上执行 (一部分) 模型推理。每个 worker 与一个 GPU 相关联,负责维护 KV Cache 并在 GPU 上执行模型推理。在分布式推理的情况下,每个 worker 被分配模型的一部分。
- ModelRunner: 执行模型推理并负责采样新 token.
- CacheEngine: 负责初始化和管理 GPU 和 CPU KV Cache. 还提供了对 KV Cache 进行操作的方法。通过
initialize_cache()
初始化。
- Worker: 在 GPU 上执行 (一部分) 模型推理。每个 worker 与一个 GPU 相关联,负责维护 KV Cache 并在 GPU 上执行模型推理。在分布式推理的情况下,每个 worker 被分配模型的一部分。
- Scheduler: 负责推理时候对请求的调度。组件包括一个 BlockSpaceManager (KV Cache blocks 管理的核心类) 以及三个队列 waiting, running & swapped.
LLMEngine Initialization
- InputPreprocessor: 主要是在
add_request()
方法中将输入的 prompt 放入 tokenizer 进行处理。 - InputRegistry: 根据目标模型对 InputPreprocessor 之后的数据进行处理。
Init Executor
1 |
|
ExecutorBase 的构造函数中会调用 self._init_executor()
对应到具体子类的函数。如果采用 TP 或 PP 的话 对应到的是 RayDistributedExecutor,否则对应到的是 UniProcExecutor. 下面以后者为例。
1 |
|
- Executor: 初始化具体的继承自 ExecutorBase 的对象,该对象的初始化过程中会调用
init_worker()
初始化 Worker (被 WorkerWrapperBase 包装),调用init_device()
初始化设备,和调用具体 Worker 对象的 model_runner 的load_model()
将模型加载到设备上。- Worker: 构造函数中会初始化
GPUModelRunnerBase
对象,确定计算 attention 使用的 backend 还有 CUDAGraphRunner 用于将模型的计算过程记录为一个静态图,在后续的推理中,通过直接 replay 这个静态图来避免动态调度和重复的内核启动开销。
- Worker: 构造函数中会初始化
initialize_kv_caches
LLMEngine 构造函数在初始化 ExecutorBase 后会调用 initialize_kv_caches()
来初始化 Worker 中的 KV Cache,流程如下:
- 该函数会首先通过 Worker.determine_num_available_blocks() 确定 GPU 和 CPU 可用的 block 数量。后者在
memory_profiling
上下文中进行 profile_run() 模拟模型在最大负载 (max_num_batched_tokens 和 max_num_seqs) 下执行一次推理。测量内存使用并分解为权重、激活张量和非 PyTorch 部分。留给 KV Cache 的内存大小为total_mem * max_utilization - weight_mem - act_mem - nontorch_mem
. 再除以每一个 block 能存储的的 KV Cache 大小cache_size = Cache_config.block_size * num_attention_layers * 2*num_heads*head_size
即可得到最多能分配多少个 GPU block. 而 CPU block 数量由预设的swap_size // cache_size
所确定。 - 确定了 GPU 和 CPU 的 block 数量后会调用 Worker.initialize_cache() 方法,里面首先会调用
Worker._init_cache_engine()
根据传入的 GPU block 个数初始化 CacheEngine (初始化 attn_backend,调用 CacheEngine._allocate_kv_cache() 为模型的每一层 transformer 开辟 CPU 和 GPU 的 KV Cache 内存),然后会调用 bind_kv_cache() 将 GPU KV Cache Tensor 绑定到对应的模型的注意力层,它筛选需要 KV Cache 的注意力层,按层索引排序并去重后为每个设备绑定对应的 Tensor. - 预热之后进行 capture_model 记录计算图。
Init Scheduler
构造函数中会初始化 BlockSpaceManager. 首先会创建一个 CpuGpuBlockAllocator
,为 CPU 和 GPU 块维护单独的内存池,并允许在这些内存池中分配、释放、分叉和交换块。它会为 CPU 和 GPU 中的 blocks 分别创建一个 BlockAlloctor
. 还会初始化一个空的 Dict[SeqId, BlockTable]
, 表示对应 seq 的 KV Cache 所使用的物理内存块。还会初始化一些调度时所需要的数据,后文再谈。
还会初始化 waiting(包含新的或 preempted prefill 请求), running & swapped(被换出的 decoding 请求), 它们是 Deque[SequenceGroup]
,其中 SequenceGroup(SG) 是一组由同一个 prompt 生成的 Sequences 和对应的采样参数。
- SequenceGroupOutputProcessor: 抽象基类借接口,会分为 SingleStepOutputProcessor (支持 beam seaching) 和 MultiStepOutputProcessor (支持 speculatice decoding)
LLM Generate
_validate_and_add_requests
里面会调用 _add_request()
给 prompt 分配 reqest_id 后会调用 LLMEngine.add_request()
将其添加到请求池中,并将在调用 LLMEngine.step()
时由调度器处理。确切的调度策略由调度程序确定。主要就是进行 tokenize,然后打包成 SG 后加入 waiting.
__run_engine
调用 generate 时首先会将 prompt 包装成 SG,它是包含某个 prompt 生成的所有 Sequence,以及一些其他在调度时需要的信息的结构。Scheduler 里面包含三个 Deque[SequenceGroup]
: waiting, running & swapped.
generate() –> _run_engine() –> step() –> Scheduler.schedule() –> Scheduler._schedule()
Scheduler 的一些操作与 BlockManager 息息相关,我们在下面先简要说明逻辑,有关其具体结构和操作流程在后文中解释。
step
执行一次 decoding 迭代并返回新生成的结果。
主要流程如下
- 调度要在下一次迭代中执行的 seq 和要交换入/出/复制的令牌块。根据调度策略,Sequences 可能被抢占/重新排序。
- 调用分布式执行器来执行模型。
- 处理模型输出。主要包括: decoding 相关输出,使用 _beam_search 与否的模型输出更新调度 seq 组和释放已完成的 seq 组。
- 读取上一次调度的元数据和输出
- 如果没有剩余步骤且,调用
Scheduler.schedule()
执行新调度,生成 seq 组元数据、调度输出和异步标志。 - 获取并重置已完成请求 ID,清理内存
- 如果不允许异步且有输出队列,处理模型输出。
- 从 Cache 获取上一次迭代的 sampled_token_ids,构造 ExecuteModelRequest 后调用
Executor.execute_model()
(最后是由 ModelRunner) 执行模型推理,获取输出。
_schedule_prefill()
- 检查 budget 是否耗尽
- 取出队列head 部的 SequenceGroup (prefill 阶段 SequenceGroup 只有一个初始 prompt Sequence)
- 计算 uncached 和 cached 的新 token 数
- 调用
BlockSpaceManager.can_allocate()
检查是否能分配足够内存。 - 若能满足 budget,从 waiting 中移除 SequenceGroup. 调用
_allocate_and_set_running()
分配内存并设置为 RUNNING 状态。
_schedule_running()
- 取出队列head 部 SequenceGroup 并计算其包含 seq 的 #uncached_token. 这里不需要 #cached_token 因为若使用 chunked prefill,该信息已经在第一次 prefill 时使用,如果不使用那么他就是进行 decoding 的 seq ,不需要用到这个信息。
- 从 running 移除该 SequenceGroup. 循环调用
Scheduler._can_append_slots()
检查是否有足够的空间存储该 SequenceGroup 的 KV Cache,若不能,进入抢占逻辑 - 从 budget 中减去当前 SequenceGroup 的 token 和 seq 数
- 若 running 有其他 SequenceGroup,抢占最低优先级(队列尾部)的,若该 SequenceGroup 只有一个正在运行的 Sequence 则抢占模式为 RECOMPUTE 加入到
preempted
,否则为 SWAP 加入到swapped_out
. - 分配 slot 并更新 blocks_to_copy,根据该 Sequence 处于 decoding(生成 1 个 token 的 KV Cache ) 或者 prefill(生成 #uncached_token 的 KV Cache) 加入到
prefill_seq_group
或者decode_seq_groups
,并更新 budget. - 返回 decode_seq_groups:存储 decoding SequenceGroup. prefill_seq_groups:存储分块 prefill SequenceGroup. preempted:被抢占需重新计算的 SequenceGroup. swapped_out:被交换到 CPU 的 SequenceGroup. keys_to_swap_out 和 keys_to_copy:内存块交换和复制的映射
_schedule_swapepd()
- 循环遍历 swapped 队列,取出队列head 部的 SequenceGroup,调用
BlockManager.can_swap_in()
(实际上是 SWAPPED 状态的can_swap
) - 获取 SequenceGroup 中处于 SWAPPED 的 Sequence 个数和 token 个数,是否满足预算。
- 调用
_swap_in
(实际上是BlockManager.swap_in()
) 执行交换,更新 blocks_to_swap_in,将 Sequence 状态由 SWAPPED 变为 RUNNING. - 调用
_append_slots
给被换入的 Sequence 分配 block. - 根据 SequenceGroup 的状态添加到不同队列。
- 返回blocks_to_swap_in:记录需要从 CPU 交换到 GPU 的块映射。blocks_to_copy:记录需要复制的块映射(例如写时复制)。decode_seq_groups 和 prefill_seq_groups:分别存储 decoding 和 prefill SequenceGroup. infeasible_seq_groups:存储无法调度的 SequenceGroup. swapped_queue:引用交换队列。leftover_swapped:暂存无法立即调度的 SequenceGroup.
_schedule_chunked_prefill()
主要思想是: 1.安排尽可能多的 decoding 请求。2.调度未完成的 prefill 请求。3.调度交换请求。4.安排新的 prefill 请求。
- 初始化 budget,限制最大批处理 token 数和 seq 数。
- 从 running 和 waiting 生成
PartialPrefillMetadata
- prefills: running 和 waiting 中未完成 prefill 的 #SequenceGroup.
- long_prefills: running 中需要进行 prefill 的 token 数很多的 #SequenceGroup.
- waiting_long_prefills: waiting 中需要进行且能进行的 (未超过 ScheduleConfig 限制) prefill 的 token 数很多的 #SequenceGroup.
- 调用
_schedule_running
. - 在 running 调度返回中无无抢占或交换时(说明有足够空间) 执行
_schedule_swapped
- 调用
_schedule_prefills
. - 更新 waiting,添加 running 调度中返回的被抢占的 seq
running_scheduled.preempted
. - 按优先级更新 running.
- swapped_in.decode_seq_groups:交换回来的 decoding 请求。
- swapped_in.prefill_seq_groups:交换回来的 prefill 请求。
- running_scheduled.decode_seq_groups:运行中的 decoding 请求。
- running_scheduled.prefill_seq_groups(按完成顺序):未完成的分块 prefill 。使用 _order_finishing_prefills_first 确保即将完成的 prefill 优先,便于下一轮转为 decoding.
- prefills.seq_groups:新 prefill 请求。
- 将运行队列中交换出去的
running_scheduled.swapped_out
添加到 swapped. - 按顺序组合所有调度的 SequenceGroup: prefill 优先(满足注意力机制假设),decoding 次之。
- 调整 lookahead_slots 数量。若所有被调度的均为 prefill 且未启用多步调度,设置 num_lookahead_slots = 0(避免推测 decoding 路径). 否则,使用 running 计算的 lookaheadh slots 数量。
_schedule_default
尽可能多地批处理 prefill 请求,然后调度 decoding 请求. 在 GPU 内存压力下,需要 preempt 或 swap out 运行中的 decoding 请求。
- swapped 为空则进行
_schedule_prefills
. - 如果没有调度任何 prefill 请求,调用
_schedule_running
. - 如果 running 调度结果中没有发生抢占或换出时 (否则说明资源不够),执行
_schedule_swapped
. - 更新 waiting, running & swapped 三个队列。
After schedule
调度结果返回后,
- 遍历调度结果中的 SequenceGroup
- 遍历该 SequenceGroup 中状态为 RUNNING 的 Sequence. 获取其数据,对应的 BlockID 列表,并更新其访问时间。若使用 prefix_caching, 则调用
BlockManager.get_common_computed_block_ids()
获取共享的已计算的部分的 BlockID 列表。 - 如果该 SequenceGroup 处于 prefill 阶段,则判断这次调度后是否能完成 prefill.
- 构造返回结果,标记所有调度 SequenceGroup 的 blocks 为已计算。
BlockSpaceManager
用于将 SequenceGroup 操作映射到其包含的对应组件的操作。
- CpuGpuBlockAlloctor: 根据是否采用 prefix caching 分别为 CPU 和 GPU 初始化一个 Alloctor
- PrefixCachingBlockAlloctor: 基于哈希值维护 block 的Cache)重用具有相同哈希值的 block,以避免冗余的内存分配。
Dict[PrefixHash, BlockId]
将用于 prefix caching blocks 的哈希值与其 BlockID 对应。Dict[BlockId, BlockTracker]
为每个物理 block 初始化一个 BlockTracker.- NaiveBlockAllocator 用于分配不作为 prefix caching 的 blocks. 有一个
RefCounter
表示某个物理 block 被多少逻辑 block 指向。 Evictor
采用 LRU 策略驱逐已经Cache) blocks.CopyOnWriterTracker
用于将原先的 block ID 映射到目的 block ID.
- PrefixCachingBlockAlloctor: 基于哈希值维护 block 的Cache)重用具有相同哈希值的 block,以避免冗余的内存分配。
- Dict[SeqId, BlockTable]: BlockTable 用于将单个 seq 的 KV Cache 映射到物理内存分配。会在调用 _allocate_sequence() 时被初始化。包含一个 BlockList (block 列表和一个表示对应 ID 的 int 列表) 和 BlockpaceManager 的 BlockAllocator.
- ComputedBlocksTracker: 维护一个
Dict[SeqId, List[int]]
( seq id到 seq 块哈希列表的映射)。Cache)个 seq 的完整块 (块全部被占满) 的哈希值。当一个 seq 进行 decoding 时,也相应更新 seq 的哈希值。还有一个Dict[int, int]
( seq id到已计算 token 数的映射)
can_allocate
在 _schedule_prefills
中被调用。
1 |
|
- 取出该 SequenceGroup 中处于 WAITING 状态的第一个 Sequence (i.e. prompt).
- 调用
BlockTable.get_num_required_blocks()
计算存储 token 和 lookahead slots 所需的最小 block 数 (假设无 prefix caching), i.e.cdiv(len(token_ids) + num_lookahead_slots, block_size)
. - 调用
BlockAlloctor.get_num_free_blocks()
获取 GPU 上空闲的 block 数 (非 prefix_caching 中的空闲个数 + 可以被驱逐的个数). - 返回分配状态
- NEVER:
#total - #required < #watermark
- OK:
#free - #required >= #watermark
- LATER:
#free - #required < #watermark
allocate
1 |
|
在 _schedule_prefills
中步骤 4 中调用的 _allocate_and_set_running
内部被调用。
- 取出该 SequenceGroup 中处于 WAITING 状态的第一个 Sequence (i.e. prompt).
- 调用
BlockManager._allocate_sequence()
创建一个 BlockTable,在获取 token_ids 列表后调用BlockTable.allocate()
为该 Sequence 分配 blocks. - 将 token_ids 按 _block_size 大小进行分块。最后一块可能不能占满一个 block.
- 对于能够占满一个 block 的 token_ids 分块,调用
BlockAlloctor.allocate_immutable_block()
. 该函数优先从Cache)查找是否已有相同内容的块,若有则直接复用该块并增加其引用计数;否则调用BlockAlloctor.allocate_mutable_blocks()
分配一个新的 block,并将 token_ids 添加到该 block 中. 该函数会尝试从非 prefix caching blocks 中分配一个 block_id,若没找到则会驱逐一个。 - 对于最后一个可能被没占满的 block 调用
BlockAlloctor.allocate_mutable_blocks()
.
can_append_slots
1 |
|
确定 GPU KV Cache 中是否有足够的空间来继续生成指定的 SequenceGroup. 上层接口为 Scheduler._can_append_slots()
,在 _schedule_running
中步骤 2 中确定是否需要进行抢占时被调用。
- 遍历该 Sequence Group 中处于 RUNNING 状态的 Sequence 对应的 BlockTable
- 调用
BlockTable.get_unseen_token_ids()
获取该 Sequence 还未被Cache) token 部分。 - 调用
BlockTable.get_num_blocks_touched_by_append_slots()
获取Cache)余部分和 lookahead 部分需要几个 block. - 调用
BlockAlloctor.get_num_free_blocks()
获取 GPU 上空闲的 block 数. - 需要个数小于空闲个数返回 True.
append_slots
1 |
|
上层接口为 Scheduler._append_slots()
. 在 _schedule_running
中检查到有空间添加,_schedule_swapped
中有 budget 进行换入,_schedule_prefills
中允许进行 chunked prefill 时被调用。
- 调用
BlockTable.append_token_ids()
. 该方法将 tokens 添加到 BlockTable 中的现有 block 中。会调用BlockTable.ensure_num_empty_slots()
, 它查看当前能够容纳多少个 token. 如果没有足够的空间,则使用BlockAlloctor.allocate_mutable_block()
方法分配新 block. - 调用
BlockAllocator.clear_copy_on_writes()
返回一个映射源 block ID 到当前 COW 的目标 block ID 的元组的列表.
_can_swap
1 |
|
根据 status 区分上层接口: RUNNING/SWAPPED 表示需要把该 SequenceGroup 处于 RUNNING/SWAPPED 状态的 Sequence 对应的 blocks 从 GPU/CPU 换到 CPU/GPU.
- 获取 SequenceGroup 中符合指定状态的 seq Sequence,然后根据 SeqID 获取对应的 BlockTable.
- 调用
BlockTable.get_num_blocks_touched_by_append_slots()
计算添加未存储 token 加上 lookahead_slots 所需的 block 数量。 - 调用
BlockAlloctor.get_num_full_blocks_touched()
获取当前有被使用的 block 数量。 - 如果总块数小于被使用的加上需要的 block 数量 返回 Never. 如果空闲块减去 被使用的加上需要的 block 数量后仍大于等于 watermark_blocks,返回 OK. 否则为 LATER.
swap_in
调用的是 self.block_allocator.swap(blocks=blocks, src_device=Device.CPU, dst_device=Device.GPU)
,即 blocks 从原设备的换出,换入到目的设备。
进一步则是 BlockAlloctor.swap_in()
,该函数遍历传入的 blocks,若已经被占满调用 BlockAlloctor.allocate_immutable_block()
. 否则调用 BlockAlloctor.allocate_mutable_blocks()
分配一个新的 block 后将原 block的 token 数据追加到新 block.
swap_out
同上,最终调用的是 BlockAlloctor.swap_out()
. 该函数对传入的每个 block 调用 _free_block_id
,逐个处理释放逻辑。若 block 有哈希值,refcount -1,若减去后为 0 则将 block 信息添加到 evictor 中,从跟踪系统中移除,然后设置 BlockId 为 None. 否则就直接设置为 None. 若无哈希值则释放 BlockId,减去对应的 refcount,但保留 block 对象本身.
Attention
XFormersImpl 中使用了 vllm 自己写的 PagedAttention kernel.
1 |
|
其中 attn_type
分为四种,下面我们主要分析 DECODER 的情况。
- DECODER: 使用 decoding 器的 self-attention block table 来Cache)KV(GPT).
- ENCODER: 不进行 KV Cache)用于 Encoder-Decoder 模编码器分支。编码器通常一次性处理整个输入 seq 。
- ENCODER-ONLY: 不进行 KV Cache)BERT).
- ENCODER_DECODER: 用于编码器- decoding 器模型中的交叉注意力部分,其中 KV seq 长度与编码器 seq 长度一致(T5).
1 |
|
AttentionMetadata 类定义如下
1 |
|
forward 方法如下,简化了成了 DECODER 情况的逻辑。
主要流程为
- 调用
PagedAttention.split_kv_cache
分离并 reshape KV Cache 张量后 调用 PagedAttention.write_to_paged_cache`
写入当前 key 和 value 到Cache)。 - 分离 prefill 和 decoding 的 token,初始化输出。对于 prefill 部分根据是否采用了 prefix_caching 调用
self._run_memory_efficient_xformers_forward
或PagedAttention.forward_prefix
计算注意力。 - 调用
get_seq_len_block_table_args
获取 decoding Sequence 对应的 BlockTable后调用PagedAttention.forward_decode
计算注意力。
1 |
|
write_to_paged_cache
调用的是已经注册到 torch.ops 中的 CUDA 函数。其对应的 host 函数为每个 token 分配一个 CUDA block,每个 CUDA block 的线程数被限制在最多 512 个。主要的 kernel 函数如下。
1 |
|
_run_memory_efficient_xformers_forward
也同样简化成 DECODER 的逻辑的情况
1 |
|
forward_prefix
不考虑 ALiBi 的情况调用的是 triton 编写的 _fwd_kernel() 每个线程块独立处理一个 Q 的一部分,对 KV Cache 和 当前 KV 分别采取 flash-attention 的计算策略。
1 |
|
forward_decode
调用的是 paged_atention_kernel
gridDim = (num_heads, num_seqs, 1). decode 的时候每个 seq 的 Query 的 toekn 数目都是 1,
- gridDim = (num_heads, num_seqs, 1): 每个线程块负责一个 seq 的 一个 head,函数定义如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23template <typename scalar_t, typename cache_t, int HEAD_SIZE, int BLOCK_SIZE, // default 16
int NUM_THREADS /*=128*/, vllm::Fp8KVCacheDataType KV_DTYPE,
bool IS_BLOCK_SPARSE,
int PARTITION_SIZE = 0> // Zero means no partitioning.
__device__ void paged_attention_kernel(
float* __restrict__ exp_sums, // [num_seqs, num_heads, max_num_partitions]
float* __restrict__ max_logits, // [num_seqs, num_heads,
// max_num_partitions]
scalar_t* __restrict__ out, // [num_seqs, num_heads, max_num_partitions, head_size]
const scalar_t* __restrict__ q, // [num_seqs, num_heads, head_size]
const cache_t* __restrict__ k_cache, // [num_blocks, num_kv_heads, head_size/x, block_size, x]
const cache_t* __restrict__ v_cache, // [num_blocks, num_kv_heads, head_size, block_size]
const int num_kv_heads, // [num_heads]
const float scale,
const int* __restrict__ block_tables, // [num_seqs, max_num_blocks_per_seq]
const int* __restrict__ seq_lens, // [num_seqs]
const int max_num_blocks_per_seq,
const float* __restrict__ alibi_slopes, // [num_heads]
// 矩阵每一维度的 stride,便于移动指针
const int q_stride, const int kv_block_stride, const int kv_head_stride,
const float* k_scale, const float* v_scale, const int tp_rank,
const int blocksparse_local_blocks, const int blocksparse_vert_stride,
const int blocksparse_block_size, const int blocksparse_head_sliding_step)
首先先计算一下当前线程对应的各种参数,这里根据模板函数定义不使用 PARTITIONING.
1 |
|
定义 thread group ,保证其一次访问的数据为 16 Bytes,需要计算其中每个 thread 处理几个元素。
1 |
|
下面将 Q 加载进共享内存。
1 |
|
假设块不稀疏并且把不采用量化,加载 K 并计算 Q@K.T. 核心思想是一个 thread group 访问 16 Bytes. 一个 thread 访问一个 vec,一个向量包含的元素个数 VEC_SIZE = 16 / sizeof (scalar_t) / THREAD_GROUP_SIZE
- 1st for 循环确定的是每次迭代中每个 warp 处理的是哪一个 block,一共要循环 num_seq_blocks / NUM_WARPS 次
- 2nd for 循环确定的是该 warp 中的每个 thread group 访问的是该 block 的第几个 token. 即每个线程组处理一个 token.
- 3rd for 循环确定的是该 thread group 中的每个 thread 访问的是第几个 vec. 该循环使得该 thread group 里面的线程读取一个完整的 headsize. 一次迭代读取的大小为 16 Bytes.
首先将 block_table 指针移动到存储该 kv cache 的首个 blockID 处,取出实际的物理块 ID,用在第三个 for 循环中将指针移动到该 K cache block 起始处. 由于
k_cache 的 shape 是 [num_blocks, num_kv_heads, head_size/x, block_size, x]
,在第三个 for 循环中 k_ptr 被移动到了该 thread_group 要读取的 block 的 token 的 head 处。vec_idx * VEC_SIZE
即为 thread 要读取的元素开始位置,/x 表示对应的是第几个 16Bytes 划分, offset1 移动的是 dim3,offset2 移动的 则是 dim4.
3rd loop 结束后已经读取了一个 K cache 的完整 head_size 到寄存器中,因此 qk 为一个 token 的一个 head 的 Score Matrix. 根据 token_idx 由每个 thread group 里的 第一个线程负责将累加和到 logits 中并更新 qk_max。
1 |
|
上面这一段结束后下面每个 warp 内 thread group 中的第一个线程已经记录了该 group 的 qk_max. 下一步则是在 warp 内进行 qk_max 归约,存储在共享内存 red_smem 中。 由于一个 warp 处理的是一个 block,相当于现在 red_smem 每个元素存储了对应 block 内的 qk_max.
1 |
|
下一步则是在 thread block 内对所有 warp 进行规约,得到该 seq 最后的 qk_max. 然后广播到所有线程中。之后每个线程计算 exp 存入 logits,每个 warp 内的 exp 求和结果存储在 red_smem 的后一半中。最后则是计算 softmax 存到 logits.
1 |
|
加载 v 的逻辑与 k 相同,但没有使用 thread group 概念,而是让一个 thread 一次加载 16 Bytes.