福生无量摸鱼天尊

LLM infra 八股全集

2025/09/03
139
0

收集我和我小伙伴互相问的八股问题,里面有gemini deep search的回答,望周知。

LLM model

rmsnorm和layernorm相比有什么优点

参考为什么最新的大模型普遍用RMSNorm?

swiglu和relu相比有什么修改

  • GELU: 高斯误差线性单元

  • ReLU: 修正线性单元

  • Swish (β=1.0): 标准Swish函数

  • Swish (β=0.5): 低β值的Swish函数

  • SwishLU: Swish + Linear Unit组合

其函数代码如下:

def gelu(x):
    """GELU激活函数"""
    return 0.5 * x * (1 + erf(x / np.sqrt(2)))

def relu(x):
    """ReLU激活函数"""
    return np.maximum(0, x)

def swish(x, beta=1.0):
    """Swish激活函数 (SiLU when beta=1)"""
    return x / (1 + np.exp(-beta * x))

def swishlu(x, beta=1.0):
    """SwishLU激活函数 (Swish + Linear Unit)"""
    # SwishLU: max(Swish(x), x)
    swish_val = swish(x, beta)
    return np.maximum(swish_val, x)

  • ReLU (修正线性单元)

    • 优点:计算简单、速度快、解决梯度消失、稀疏激活

    • 缺点:"死亡ReLU"问题、非对称、在x=0处不可导

  • GELU (高斯误差线性单元)

    • 优点:光滑连续可导、基于概率理论、Transformer中表现优异

    • 缺点:计算复杂度高、训练速度较慢

  • Swish (β=1.0)

    • 优点:光滑、非单调、无上界、自门控机制、梯度流动好

    • 缺点:计算复杂度高、参数需调优、可能梯度爆炸

  • Swish (β=0.5)

    • 优点:更加平滑、负值激活温和、数值稳定性更好

    • 缺点:激活强度较弱、深度网络表现可能不如β=1

  • SwishLU (实验性)

    • 优点:结合Swish光滑性和线性增长、避免梯度消失

    • 缺点:新颖函数、理论研究不足、实际应用验证不充分

这也是为什么llama选择了

为什么deepseek把激活函数换成了sigmoid

该转变是由Deepseek 的V2 从softmax转变成了Deepseek V3的sigmoid函数,首先来看一下架构的对比:

  • V2版。路由专家数: 每个 MoE Layer 包含 162 个专家,其中 2 个是共享专家,160 个是路由专家,每个 Token 激活 2+6=8 个专家。共 236B 参数量,每个 Token 激活 21B 参数。

  • V3版。路由专家数: 256, 激活专家数:8个, 模型总参数671B,激活参数37B

原因可能如下:

  • V3相对于V2的路由专家数增加了近100个,softmax要在内部对所有维度的值做归一化处理。因为所有维度加和要等于1,所以维度越大,每个维度值理论上分配的值就越小,导致激活更不准确,计算误差会加大。而且DeepSeek-V2时已经采用了FP32的精度。这样在选取 TopK 个最大值时,对更小的小数位会更敏感,导致数据区分度不高,维度越大,问题越严重。

  • Sigmoid函数的值域更宽,更适合高维度操作。Sigmoid函数的是对每个专家分别计算一个 [0,1] 的打分,它并是不随专家维度变化而变化,理论上计算的打分值域更宽,区分度更高。

  • 在算子来说,softmax需要遍历,而sigmoid不需要

  • 参考文章里面还有梯度下降的视角,讲的非常好非常好,推荐阅读

参考资料4.1.1 Sigmoid

请说明LLama为什么要用SwiGLU激活函数?请说明SwiGlu的原理。该激活函数和SwishLU 的优缺点是什么

SwiGLU 是一种门控线性单元(Gated Linear Unit, GLU)的变种,结合了 Swish 激活函数:

SwiGLU(x) = Swish(xW + b) ⊙ (xV + c)

  • GLU 架构通过门控机制提供了更强的非线性表达能力

  • 门控机制允许模型选择性地传递信息,类似于 LSTM 的门控

  • 相比标准激活函数,SwiGLU 在大规模训练中表现更稳定

llama为什么引入了siluandmul

把swiglu和ffn融合在一起了

Transformer

嗟嗟——强化学习与大模型推理你需要知道的108个问题(3. Transformer)

嗟嗟——强化学习与大模型推理你需要知道的108个问题(4. Transformer细节之问)

嗟嗟——强化学习与大模型推理你需要知道的108个问题(5. Transformer与LLM架构)

如何实现moe里的共享专家

取决于多少个共享专家,比如40个,就是一个40xhead_dim的mlp层

去看下vllm里面model的实现

请简述一下MHA,MQA和GQA

GQA全称grouped query attention,是一组query向量对应一对kv向量进行attention。为什么要有gqa,是因为原生的mha在存储kv向量时要求的显存较大,引入gqa可以显著减少所需的kv向量的显存占用。事实上在减少显存占用的研究路途上在gqa之前还有mqa,但是mqa在性能表现上低于原生的mha,而gqa与mha持平,甚至比原生的mha高,因此采用了gqa

参考文献:

理解Attention:从起源到MHA,MQA和GQA

LLM inference

vllm v1 和SGLang区别

两者的差异由颗粒度的不同,能讲的非常多,同时更新日新月异,这里的版本不一定是最全的,慢慢补吧。

(坑太大了,晚上写吧)

从PD分离开始,P和D分开来讲

参考文献:

vLLM和SGLang的比较(最新版本)

解释vLLM调度器的详细逻辑。它如何区分处理prefill和decode请求?

vLLM调度器的核心任务在每个执行步骤中,智能地从众多请求中挑选出一个组合,以填满GPU的计算能力,同时保证公平性和响应性。其逻辑围绕着prefill和decode两种不同的计算模式,以及一个固定的token预算展开。

Prefill与Decode的区分与统一: 在LLM推理中,存在两种截然不同的计算阶段 :  

  • Prefill(预填充):处理用户输入的整个prompt。这是一个并行度很高的计算过程,所有prompt token可以一次性输入模型进行计算。这个阶段通常是计算密集型的。

  • Decode(解码):在prefill之后,逐个生成输出token。每次只处理一个新token,但需要依赖前面所有token的KV Cache。这个阶段是自回归的,通常是访存密集型的。

vLLM v1版本的一个重要改进是,调度器在逻辑上统一了prefill token和decode token 。它不再严格区分“prefill阶段”和“decode阶段”的请求,而是将所有需要处理的token(无论是来自新请求的prompt,还是来自正在运行请求的下一个token)都视为待处理的任务。这种统一的表示方式极大地简化了调度逻辑,并自然地支持了诸如分块预填充(Chunked Prefill)等高级功能。例如,一个非常长的prompt可以被拆分成多个块,在几个调度步骤中逐步完成prefill。  

(这里可能要补一下v1新的token逻辑)

描述一下LLM调度器的调度策略(如FCFS)如何在固定的token预算内管理等待队列和运行队列。

vLLM的调度器在一个循环中工作,每个循环决定一个批次(step)的执行内容。其默认调度策略是FCFS(先来先服务) 。  

调度逻辑如下 :  

  • 设定Token预算:调度器在一个固定的token预算内工作,这个预算由一个关键参数max_num_batched_tokens定义。这个参数代表了单个批次中能够处理的最大token数量。

  • 清空与排序:在每个调度周期开始时,调度器会首先将上一轮完成的请求从运行队列中移除。然后,它会将所有可运行的请求(包括等待队列中的新请求和运行队列中待生成的请求)根据调度策略(如FCFS)进行排序。

  • 分配预算:调度器按照排序,依次遍历请求,为它们分配token处理名额,直到token预算被耗尽。

    • 优先处理运行中请求:通常会优先为运行队列中的请求分配一个token的decode名额,因为它们是正在进行中的任务。

    • 填充新请求:在为运行中请求分配完名额后,如果token预算还有剩余,调度器会开始从等待队列中取出新请求,为它们分配prefill名额。它可以为一个新请求分配其全部prompt长度的名额,或者在预算不足时,只分配一部分(即Chunked Prefill)。

  • 决策生成:最终,调度器会生成一个简单的决策字典,例如{request_id_1: 1, request_id_2: 1, request_id_3: 512},表示在接下来的这一个step中,为请求1和2各处理1个decode token,为新来的请求3处理512个prefill token 。  

  • 内存检查:在做出最终决策前,调度器会与KV Cache管理器通信,确保有足够的空闲物理块来存储即将生成的KV状态。如果没有,即使token预算有余,相应的请求也无法被调度。

这种基于token预算的统一调度机制,使得vLLM能够非常灵活地在计算密集型的prefill任务和访存密集型的decode任务之间取得平衡,从而持续地让GPU处于饱和状态。

藕汤:

  • 以step为单位,初始情况下两个队列肯定都为空,进入的request一般都是prefill,先来先得,比如budget是10个token,你的一个requeset的prefill是100,那后面进来的request都要乖乖等着,等这个request的prefill做完

  • p做完了会做decode,后面的request就可以进来做prefill了

  • 当然,在prefill的阶段可以做个优化,不让其他request等太久,就是chunked prefill了

  • 你把每个request的prefill在每次step中固定一个大小,比如5或20,这样空出来的budget可以让其他request的chunk占据

  • 这个策略是为了应对短request的饥饿问题的

什么是PagedAttention和Continuous Batching?处于什么原因vllm要使用它?

PagedAttention和Continuous Batching是vLLM性能飞跃的两大基石

  • PagedAttention(分页注意力):这是一种新颖的注意力算法,其核心思想借鉴了操作系统中的虚拟内存和分页机制来管理KV Cache。它将每个请求序列的KV Cache分割成固定大小的块Block(vllm里是16个kv cache一个block),这些块在物理显存中可以非连续存储。系统通过一个“块表”(Block Table)来维护逻辑块到物理块的映射关系。这种方式几乎完全消除了内存碎片,使得显存利用率接近最优(浪费低于4%),从而允许在有限的显存中容纳更多的并发请求。

    • PagedAttention通过极致的内存优化,极大地提升了系统能够承载的并发请求数量(即有效批次大小)

  • Continuous Batching(连续批处理):这是一种先进的请求调度策略,旨在最大化GPU的利用率vllm会把一批requesttoken组成一个budgettoken。与等待整个budget所有请求都完成后再处理下一个budget的静态批处理不同,连续批处理在budget层面进行调度。一旦budget中的某个请求序列生成了结束符(EOS token),系统会立即释放其占用的资源,并从等待队列中填充一个新的request,使GPU始终处于满负荷工作状态

    • Continuous Batching则确保了无论请求长短如何变化,GPU的计算单元都能被这些高密度的请求持续填满。

简述一下kv cache

kv cache产生的原因是通过attention的计算公式发现,每增长一个token实际上需要增多的只有Q向量,因此使用了cache技术将kv向量缓存下来来加速attention的计算。诚然kvcache的出现加速了attention的计算,但是也让gpu的显存负担明显增高。所以后面出现了GQA,mla等技术,同时kv cache的引入也产生了新的问题,如何去管理这些kv cache,kv cache的诞生使显存有了内部和外部碎片,为了解决管理的问题又引入了paged attention。

PagedAttention在vllm中执行的流程是什么?

PagedAttention的原理是对操作系统中经典的虚拟内存管理思想在GPU显存管理领域的精妙应用,其核心目标是解决KV Cache动态、可变长度带来的内存管理难题。

工作流程:

  • 内存池化vLLM启动时,会根据可用显存和块大小,创建一个巨大的物理块池。

  • 按需分配:当一个新请求进入(prefill阶段)或一个已有请求生成新tokendecode阶段)需要更多KV Cache空间时,内存管理器会从物理块池中按需分配一个或多个空闲的物理块。

  • 块表更新分配到的物理块的地址会被记录在对应请求序列的块表中,与相应的逻辑块索引关联起来。由于物理块是从池中任意取出的,它们在物理显存上的地址是非连续的

  • Attention计算:在执行Attention计算的CUDA/Triton Kernel中,不再传入一个连续的KV Cache张量指针。取而代之的是,Kernel会接收到这个请求的块表。当需要访问某个tokenKeyValue时,Kernel会首先通过计算确定它属于哪个逻辑块,然后查询块表找到对应的物理块地址,最后在物理块内部进行偏移寻址。

PagedAttention如何最小化内存碎片?

PagedAttention通过以下方式有效解决了内存碎片问题 :

  • 消除外部碎片 (External Fragmentation):所有物理块的大小都是固定的。当一个请求结束,它所占用的物理块被完整地释放回池中。由于所有空闲块都等价且可互换,系统永远不会出现“有足够总量的空闲内存,却没有一块足够大的连续内存”的窘境。

  • 最小化内部碎片 (Internal Fragmentation):内部碎片只存在于每个序列所占用的最后一个物理块中。因为块的大小通常远小于整个序列的KV Cache大小(例如,一个块可能存储16个token的KV),所以这部分浪费的比例极低。实践中,总的内存浪费率可以控制在4%以下 。  

然而,值得注意的是,PagedAttention的这种设计并非没有代价。它使得KV Cache在虚拟内存层面也变成了非连续的,这就要求Attention Kernel必须重写,以支持通过块表进行间接寻址,这会带来一定的性能开销 。

描述Continuous Batching的机制。它与静态批处理(Static Batching)和动态批处理(Dynamic Batching)有何不同?

Continuous Batching(连续批处理),又称In-flight Batching,是针对LLM推理特性设计的一种高效调度策略,其核心在于将调度的粒度从“请求级”细化到“迭代级” 。  

与其它批处理方式的对比 :  

  • 静态批处理 (Static Batching):服务器等待收集到固定数量的请求后,将它们打包成一个批次进行处理。整个批次必须等待其中最慢(生成最长)的请求完成后才能结束,这期间GPU资源会被严重浪费。

  • 动态批处理 (Dynamic Batching):这是静态批处理的改良版。服务器设定一个时间窗口,处理在该窗口内到达的所有请求。这缓解了请求的等待时间,但同样存在“头节点阻塞”(Head-of-Line Blocking)问题,即批次内的短请求必须等待长请求。

  • 连续批处理 (Continuous Batching):它彻底打破了批次的固定边界。调度器在模型的每一次前向传播(即生成一个token的迭代)后都会重新评估和组合批次。一旦某个请求完成,它的位置会立即被等待队列中的新请求填补。

在vllm中Continuous Batching是如何进行的

vLLMscheduler通过管理两个核心队列来实现连续批处理:

  • 等待队列 (Waiting Queue):存放已到达但尚未开始处理的请求。

  • 运行队列 (Running Queue):存放正在进行token生成的请求。

其分步逻辑如下:

  • 请求入队:新的推理请求到达后,被放入等待队列。

  • 动态批次组合:在每个生成步骤(iteration)开始时,调度器会创建一个新的批次。它会首先遍历运行队列中的所有请求,将它们加入到当前步骤的批次中。

  • 填充批次:接着,调度器会检查当前GPU的负载和可用显存(由PagedAttention的内存管理器提供信息)。如果还有容量,它会从等待队列的头部取出新的请求,为它们分配初始的KV Cache块,并将它们也加入到当前批次中。这些新请求将执行它们的“prefill”阶段。

  • 单步执行:组合好的批次(包含正在decode的老请求和正在prefill的新请求)被送到GPU上,执行一次模型的前向传播。、

  • 状态更新与资源释放:执行完毕后,调度器检查批次中每个请求的状态。

    • 如果一个请求生成了EOS token或达到了最大长度,它就被视为完成。调度器会立即释放该请求占用的所有KV Cache物理块(将其归还到内存池),并将该请求从运行队列中移除。

    • 对于未完成的请求,它们会继续留在运行队列中,等待下一个生成步骤。

  • 循环:调度器不断重复步骤2到5,确保GPU的计算能力在每个迭代周期都被尽可能地填满。

通过这种机制,vLLM确保了GPU不会因为等待长请求而空闲,极大地提升了系统的整体吞吐量和资源利用率,并显著降低了请求的平均延迟 。  

vLLM v1版本特别针对降低CPU开销进行了优化。请问这些CPU开销的来源是什么?新的多进程架构和隔离的EngineCore是如何解决这个问题的?

这些CPU开销主要来源于以下几个方面 :  

  • API服务器处理:接收HTTP请求、解析参数、管理客户端连接等。

  • 输入预处理:对传入的文本进行分词(Tokenization),对于多模态模型还包括图像等输入的处理。

  • 调度逻辑:运行调度算法,决定下一个批次包含哪些请求。

  • 输出后处理:将模型生成的token ID反分词(De-tokenization)为文本。

  • 结果流式传输:将生成的文本通过网络流式返回给用户。

在vLLM的早期版本中,这些CPU任务与GPU的模型执行任务是在同一个事件循环中串行或交错执行的。当GPU执行速度极快时,CPU就无法及时准备好下一次迭代的数据,导致GPU出现“等待CPU”的空闲气泡,从而限制了整体吞吐量。

为了解决这个问题,vLLM v1引入了更深度的多进程架构,其核心是创建了一个隔离的EngineCore执行循环 。

  • 架构设计:系统被拆分为两个(或多个)独立的进程。一个或多个进程负责处理CPU密集型任务(如API服务器、分词/反分词),而另一个独立的进程专门运行EngineCore

  • EngineCore的职责EngineCore进程只专注于最核心的、与GPU交互的任务,即调度器(Scheduler)和模型执行器(Model Executor)

  • 进程间通信 (IPC):这些进程之间通过高效的IPC机制(如ZeroMQ)进行通信。API进程将分词后的请求发送给EngineCoreEngineCore执行完毕后将生成的token ID返回。

  • 实现并行与重叠:这种架构的最大优势在于实现了CPU任务和GPU任务的真正并行。当EngineCore在GPU上执行模型前向传播时,其他进程可以在CPU上同时处理下一个请求的分词、上一个请求结果的反分词以及网络IO。CPU和GPU的工作被有效地重叠(Overlap)起来,从而掩盖了CPU端的延迟,确保了GPU能够持续不断地接收到任务,最终最大化了模型的吞吐量。

请描述PagedAttention在Attention算子(Kernel)本身内部引入了哪些性能开销?

PagedAttention的性能开销来源: cuda计算中很大程度上都是使用了连续访问的内存,pagedAttention引入的blockTable就导致了合并访存率大大降低,让效率变低,每次从gmem中读数据几乎都是从不同且不连续的地址读取的,合并访存率会低很多。

  • 额外的访存开销:在计算每个Query与Key的点积之前,Kernel必须首先访问块表,以查明存储相关Key/Value的物理块的实际地址。这是一次额外的、可能不连续的内存读取操作。

  • 额外的计算开销:Kernel需要执行额外的指针运算和分支指令来计算正确的物理地址。vLLM的论文承认,这部分开销主要来自块表查找和额外的分支执行,导致其PagedAttention实现比原始的FasterTransformer Kernel慢了20-26% 。  

  • 对Kernel优化的阻碍:非连续的内存布局使得一些针对连续内存设计的深度优化(如向量化加载、更复杂的访存模式)变得困难或无法应用。例如,FlashAttention和FlashInfer的PagedAttention版本被证实比其原生(vanilla)版本慢12%到28% 。  

【不重要】最近提出的vAttention是如何旨在通过利用底层系统对按需分页的支持来解决这些问题的?

vAttention的解决方案: vAttention的提出,是对PagedAttention局限性的一次深刻反思和釜底抽薪式的解决方案。其核心思想是:将动态物理内存管理的复杂性下沉到更底层的系统(GPU驱动/硬件),同时为上层的Attention Kernel保留一个简单、高效的连续虚拟地址空间 。  vAttention的工作原理如下:

  • 保留虚拟地址连续性:与PagedAttention不同,vAttention在为KV Cache分配内存时,会请求一个巨大的、连续的虚拟地址空间。这意味着从Attention Kernel的视角看,KV Cache就像一个普通的、连续的大数组,可以直接进行高效的指针运算,无需任何块表查找。

  • 利用按需分页(Demand Paging)vAttention并不立即为这个巨大的虚拟地址空间分配相应的物理显存。它依赖于现代GPU和操作系统已经支持的按需分页机制。只有当Kernel第一次尝试访问这片虚拟地址空间的某个页面(page)时,才会触发一个“缺页中断”(page fault),此时GPU驱动/硬件层面才会为其分配一个物理显存页面,并建立虚拟地址到物理地址的映射。

两全其美:通过这种方式,vAttention实现了两全其美的效果:

  • 对于Kernel:它操作的是一个简单的、连续的虚拟内存布局,可以运行任何未经修改的高性能Attention Kernel(如原生FlashAttention),从而消除了PagedAttention引入的访存和计算开销。

  • 对于系统:物理显存仍然是按需、非连续地分配的,从而同样达到了避免内存碎片、提高内存利用率的目的。

vAttention的出现,体现了系统设计中一个经典的演进模式:当一个在应用层实现的复杂抽象(PagedAttention的内存管理器)暴露出性能瓶颈时,更优的方案往往是去利用底层硬件或操作系统已经提供的、更高效的同类原生功能。

有哪些可以衡量推理系统的指标

引入向量化技术是为什么?有什么好处

参考文献:

深入浅出GPU优化系列:elementwise优化及CUDA工具链介绍

LLM Train

简述一下DP TP PP EP各自是什么,各自解决的问题、使用场景和优缺点

  • 数据并行(DP,Data Parallelism)

    • DP是每张卡拷贝相同的模型结构,仅对数据做切分。在训练时体现为不同batch进入不同的gpu节点进行训练,每张卡计算完的梯度也是针对各自数据的,可以通过ring all reduce方式将梯度结果传到某一张卡上进行梯度下降,下降完了还要给其他模型进行同步。

    • 其数学基础如下:

      \frac{\partial Loss(D)}{\partial w}=\frac{\partial\frac{1}{N}\sum_{i=1}^{N}Loss(x_i,y_i)}{\partial w}=\frac{1}{N}\sum_{i=1}^{N}\frac{\partial Loss(x_i,y_i)}{\partial w}
    • DP特点是计算快,瓶颈就是通信带宽高

  • 张量并行(TP,Tensor Parallelism)

    • tp的话是张量并行,具体表现是将模型内部的参数矩阵切分,分发到不同的gpu中。由于发放的参数矩阵是tensor,所以叫张量并行。值得注意的是tp并不是所有模块都能使用的,MLA不能用TP。

    • 不同的模型层在TP中划分不一样,详情可以看参考文献1.

    • 张量并行的优点是能分摊模型到多张卡上,缺点是带来了不小的通信开销,影响训练效率

  • 流水线并行(PP,Pipeline Parallelism)

    • 由于大模型很大程度上是重复模块的堆叠,重复的流程很多,常规的PP会增大训练的气泡率,所以要对PP进行改进,缓解每个gpu的显存压力,以及加快推理速度。具体的PP改进也是看参考文献1 。

    • 张量并行是对矩阵进行切分,缺点是通信量很大。如果按照layer进行切分通信量就会少很多,少了同层之间的通信。

  • 专家并行(EP,Expert Parallelism)

    • 专家并行主要解决的是MoE模型的问题,将不同的MoE 专家放进不同的GPU,然后使用router进行分流。

参考文献:

大模型训练并行技术理解-DP/TP/PP/SP/EP

算子

基础问题大全

速通CUDA

对比和比较使用Triton与原生CUDA C++编写自定义GPU算子。它们各自的编程模型、抽象层次和理想使用场景是什么?

特性

CUDA C++

Triton

编程语言

C++的扩展

Python-like DSL (Domain-Specific Language)

抽象层次

低层次。直接操作线程、线程块、Warp、共享内存等硬件原语。

高层次。以“块”(Block)或“Tile”为单位进行编程,隐藏了线程级的复杂性 。

编程模型

SIMT (单指令多线程)。程序员编写单个线程执行的代码,该代码在GPU上由成千上万的线程并行执行。

块级编程(Block-level Programming)。程序员定义对数据块(Tile)的操作,Triton编译器负责将这些块级操作映射到具体的SIMT执行单元上 。

开发效率

。需要深厚的GPU架构知识,代码冗长,调试困难。

。语法简洁,接近Python/NumPy,使没有CUDA经验的研究人员也能快速编写出高性能算子 。

性能控制

完全控制。开发者可以手动优化内存访问模式、线程同步、指令调度等所有细节,理论上可以达到硬件性能的极限 。

自动优化。编译器自动处理内存合并、共享内存管理、指令调度等复杂优化,开发者只需关注算法逻辑。性能通常能与专家手写的CUDA代码媲美,甚至超越 。  

编译流程

CUDA C++ -> PTX (中间表示) -> SASS (机器码)

Triton (Python) -> LLVM-IR -> PTX -> SASS  

理想使用场景:

  • Triton的理想场景

    • 快速原型验证与迭代:当需要为新的神经网络层(如FlashAttention、RMSNorm)快速开发高性能算子时,Triton的开发效率优势巨大。

    • 深度学习研究:使研究人员能够专注于算法创新,而无需分心于底层的CUDA编程细节。

    • 大多数DNN算子:对于常见的、结构化的计算密集型任务(如矩阵乘法、卷积、各种Attention变体),Triton的编译器优化通常足以生成接近硬件极限性能的代码。一个25行的Triton矩阵乘法算子就能达到与NVIDIA高度优化的cuBLAS库相匹配的性能 。  

  • CUDA C++的理想场景

    • 极致性能压榨:当一个算子是系统的绝对性能瓶颈,且Triton生成的代码仍有优化空间时,需要用CUDA进行手动调优。

    • 非结构化或复杂的并行模式:对于需要复杂的线程间通信、精细的Warp级操作(warp-level primitives)或不规则内存访问的算法(例如某些图算法、快速傅里叶变换FFT),Triton的高层抽象可能不足以表达最优的实现方式 。  

    • 与底层库的深度集成:当需要直接调用NVIDIA的底层库(如cuFFT, CUB)或利用特定的硬件功能时,使用CUDA是必然选择。

解释Triton是如何通过自动化内存合并(Memory Coalescing)和共享内存管理来简化GPU编程的。它的块级编程模型与CUDA的SIMT模型有何不同,又是如何帮助实现这一点的?

Triton通过其独特的块级编程模型和强大的编译器,解决的就是CUDA最麻烦的两点:内存合并和共享内存管理。为此,triton设计了两个自动优化模块:自动化内存合并 和 自动化共享内存管理。

自动化内存合并 (Automatic Memory Coalescing):

  • CUDA中的挑战:在CUDA中,为了实现高性能,开发者必须手动确保一个Warp(通常是32个线程)中的线程访问的是连续的全局内存地址。这种合并访问可以被GPU硬件聚合成一次或少数几次内存事务(memory transaction),从而最大化利用显存带宽。如果访问是分散的(uncoalesced),则可能导致32次独立的内存事务,带宽利用率极低 。这需要开发者精心设计线程索引和数据布局。  

  • Triton的解决方案:Triton的编程模型操作的是数据块(tl.block,而非单个线程 。当你写下tl.load(pointer)时,你是在告诉Triton加载一个数据块。Triton编译器会接管后续工作:它分析这个块级加载操作,并自动生成底层的PTX指令,安排Warp内的线程以最优的方式(即合并的模式)从全局内存中读取数据。开发者无需关心每个线程具体加载哪个地址,只需从逻辑上操作数据块即可 。  

自动化共享内存管理 (Automatic Shared Memory Management):

  • CUDA中的挑战:共享内存(Shared Memory / SRAM)是GPU上一种速度极快但容量很小的片上内存。为了复用数据(例如在矩阵乘法中),标准的CUDA做法是:手动声明__shared__内存数组,编写代码让线程协同地将数据从全局内存搬运到共享内存,使用__syncthreads()进行线程同步,计算完毕后再写回。这个过程非常繁琐且极易出错。

  • Triton的解决方案:Triton完全自动化了这一过程 。当开发者在一个Triton Kernel中加载一个数据块,并在后续的计算中多次使用它时(尤其是在计算密集型操作如 tl.dot中),Triton编译器会通过**活跃范围分析(liveness analysis)**来识别这种数据复用模式。它会自动决定将这些频繁使用的数据块“提升(promote)”到共享内存中进行缓存,而无需开发者编写任何与共享内存相关的代码。编译器会自动生成加载、同步和读取共享内存的指令。

块级编程模型 vs. SIMT模型: 这种自动化的核心在于编程模型的抽象层次不同:

  • CUDA的SIMT模型以线程为中心的。它给了开发者极致的自由度,但也把优化的重担完全交给了开发者。编译器很难从单个线程的代码中推断出整个线程块的宏观协作模式。

  • Triton的块级模型以数据为中心的。开发者描述的是对数据块的整体操作。这种更高层次的语义信息给了编译器更大的优化空间。编译器能够看到“加载一个tile”、“对两个tile做点积”这样的宏观意图,从而可以全局地、系统性地规划最优的数据流路径(是从HBM直接到寄存器,还是先经由SRAM)和执行策略,而这正是自动化优化的关键。

在GPU算子设计的语境下,FlashAttention论文中提出的“IO感知”(IO-Awareness)是什么意思?

“IO感知”(IO-Awareness)是FlashAttention论文提出的核心思想,它指的是在设计算法时,必须清醒地意识到并精细地管理不同层级存储(IO)之间的 数据移动,因为这在现代GPU上是主要的性能瓶颈 。  在GPU体系结构中,存在一个显著的性能鸿沟:

  • 高带宽内存 (HBM):容量大(几十GB),但速度相对较慢。

  • 片上SRAM:容量极小(几MB),但速度极快,比HBM快几个数量级。

现代GPU的算力增长速度远超HBM带宽的增长速度,导致许多计算(尤其是Transformer中的操作)都受限于内存访问速度,而非浮点计算能力。因此,一个“IO感知”的算法,其首要目标就是最大化地利用快速的SRAM,最小化对慢速HBM的访问次数 。  

“切片”(Tiling)技术是如何帮助减少慢速HBM和快速SRAM之间的内存读写次数的?

“切片”(Tiling)技术的作用: Tiling是实现IO感知的关键技术。它通过将计算过程重构,把大的输入矩阵分解成小的块(Tiles),使得这些小块可以完全载入到SRAM中进行处理。在Attention的场景中,Tiling通过以下方式减少HBM的读写 :  

  1. 避免物化巨大的Attention矩阵:标准的Attention实现会计算并向HBM写入一个大小为 N×N 的Attention Score矩阵(N为序列长度)。当N很大时,这个矩阵会占用巨大的显存(O(N2)),并且读写它会消耗大量的HBM带宽。Tiling技术通过分块计算,完全避免了将这个巨大的中间矩阵写入HBM

  2. 分块计算流程:FlashAttention的Kernel并不会一次性处理整个Q, K, V矩阵。相反,它的外层循环遍历K和V矩阵的块(Tiles)。

    • 在每次外层循环中,它将一个K块和一个V块从HBM加载到SRAM中。

    • 然后,内层循环遍历Q矩阵的块。在每次内层循环中,它将一个Q块加载到SRAM。

    • 在SRAM内部,这个Q块与已加载的K块计算出局部的Attention Score,并用这个Score去加权V块,得到一个局部的输出结果。

    • 这个局部输出结果会与之前所有块计算得到的累积结果进行合并。

  3. 在线Softmax (Online Softmax):Softmax操作需要对一行中的所有元素进行归一化,这看似需要完整的N×N矩阵。FlashAttention巧妙地通过一种数值稳定的在线算法,在逐块计算的过程中,只维护和更新行最大值和归一化因子这两个小的中间状态,从而在不看到完整矩阵的情况下,也能计算出正确的Softmax结果。

  4. 为反向传播重计算:为了进一步节省显存,FlashAttention在反向传播时不会从HBM读取前向传播时计算的N×N Attention矩阵(因为它根本就没存)。取而代之的是,它利用前向传播时保存的Softmax归一化因子,在SRAM中重新计算所需的Attention值。虽然这增加了浮点运算量(FLOPs),但由于避免了大量的HBM读操作,总的执行时间反而更快。

综上所述,Tiling通过将计算分解为可以在高速SRAM中完成的多个小任务,极大地减少了对慢速HBM的依赖,是实现IO感知算法的核心手段。

@triton.autotune是什么,有什么用?

@triton.autotune 自动调优: @triton.autotune是一个装饰器,它能自动为Triton Kernel寻找最优的配置参数组合 。  

  • 工作原理:开发者可以为Kernel定义一组triton.Config对象。每个Config对象代表了一套不同的“元参数(meta-parameters)”,例如BLOCK_SIZE_M, BLOCK_SIZE_N, num_warps等。当被@triton.autotune装饰的Kernel首次以特定输入尺寸运行时,Triton会为每个Config配置编译并运行一个微基准测试(micro-benchmark),然后缓存下性能最好的那个配置。后续所有相同输入尺寸的调用都将直接使用这个最优配置。

  • 使用方法

    Python

    @triton.autotune(
        configs=,
        key=['M', 'N', 'K'], # 根据这些输入参数的值来缓存最优配置
    )
    @triton.jit
    def matmul_kernel(A, B, C, M, N, K,...):
        #... kernel code
    
  • 重要性:最优的配置参数高度依赖于具体的GPU架构(如SM数量、SRAM大小)和输入张量的形状。手动寻找最优值是一个极其繁琐的过程。Autotuning将这个过程自动化,使得编写的Kernel能够自适应地在不同硬件和不同问题规模上都达到接近最优的性能。

什么是算子融合(Kernel Fusion),它如何通过减少Kernel启动开销和内存流量来提升性能?

算子融合是将多个独立、连续的计算操作合并到一个单一的GPU Kernel中执行的技术 。  

  • 解决的问题

    1. Kernel启动开销 (Launch Overhead):每次从CPU调用一个GPU Kernel都有一定的开销(几微秒)。对于许多计算量不大的“逐元素(element-wise)”操作(如加法、ReLU激活),这个启动开销甚至可能超过实际的计算时间。

    2. 内存流量 (Memory Traffic):在未融合的情况下,每个算子的输出结果都必须先写回慢速的HBM,然后下一个算子再从HBM中把它读出来。这一来一回的读写是巨大的性能浪费。

  • 融合后的优势

    • 通过将例如Matmul -> Bias Add -> ReLU这三个操作融合成一个Kernel,我们只需要一次Kernel启动。

    • 更重要的是,Matmul计算出的中间结果可以直接保存在GPU核心的高速寄存器(registers)或SRAM中,然后立即用于Bias Add和ReLU的计算,最后才将最终结果写回HBM。这完全消除了中间结果在HBM上的读写,极大地节省了显存带宽 。  

请用Triton写一个Fused RMSNorm算子。请阐述您的设计过程。您将如何构建这个Kernel?如何选择切片大小(BLOCK_SIZE)?为了最小化内存访问,您会融合哪些关键操作?

RMS公式如下:

y_i=\frac{x_i}{\sqrt{\frac{1}{n}\sum_{j=1}^nx_j^2+\epsilon}}\cdot g_i

  • 并行维度:RMSNorm是在最后一个维度(ModelDim,即特征维度)上进行归一化的。因此,我会让每个Triton程序实例(program instance)负责处理一个token的归一化。并行化将在Batch和Sequence Length维度上展开。

  • 数据加载:在Kernel内部,每个程序实例会加载其对应token的完整特征向量(长度为ModelDim)。

  • 块内计算 (Tiling):由于ModelDim可能很长(例如4096或更大),无法一次性加载到寄存器中。因此,我会使用Tiling技术。程序将以BLOCK_SIZE为单位,分块加载这个特征向量。

# 伪代码
row_ptr = X + pid * stride_x # 指向当前token的特征向量
accumulator = 0.0
for offset in range(0, D, BLOCK_SIZE):
    # 1. 加载一个数据块
    mask = tl.arange(0, BLOCK_SIZE) + offset < D
    x_chunk = tl.load(row_ptr + offset, mask=mask, other=0.0)
    # 2. 在寄存器中计算平方和
    accumulator += tl.sum(x_chunk * x_chunk)
  • 块间归约 (Reduction):上述循环计算了每个程序实例的局部平方和。如果使用了多个Warp,还需要在Warp之间进行一次归约,得到整个向量的平方和。

  • 归一化与写回:得到总的平方和后,在寄存器中完成后续的Mean, Add Epsilon, Rsqrt计算,得到归一化缩放因子。然后,再次以BLOCK_SIZE为单位循环遍历特征向量,加载x_chunk和对应的g_chunk,在寄存器中完成归一化和缩放,最后将结果写回输出地址。

BLOCK_SIZE的选择: BLOCK_SIZE是性能的关键。

  • 选择依据:它应该是一个2的幂次方,以便于编译器优化。其大小需要在多个因素间权衡:

  • L1缓存/共享内存容量:选择的BLOCK_SIZE以及相关的中间变量应能很好地装入L1缓存或共享内存,以最大化数据复用 。  

  • 寄存器压力:过大的BLOCK_SIZE可能导致寄存器溢出(spilling)到本地内存,反而降低性能。

  • 并行度BLOCK_SIZE决定了每个线程块内部的并行度。

  • 调优方法:我会将BLOCK_SIZE作为一个可调参数,并使用@triton.autotune来系统性地测试一系列候选值(例如,128, 256, 512, 1024),让Triton自动找到在目标硬件上针对特定ModelDim的最优值 。  (看最后一个问题)

那就roofline模型有什么用?如何确定最优的BLOCK_SIZE

  • roofline模型事实上是斜线和一条横线。横坐标一般是计算强度,纵坐标是性能,一般都是每秒硬件能做多少操作

  • 斜线部分表示模型处于memory bound,这个时候可以通过增加计算强度让模型性能提高,具体方法比如增大gemm的矩阵大小,当遇到横线时说明达到了comoute bound了,此时模型性能已经达到理论极限。计算强度的提高对性能的提高已经没有什么帮助了

  • 选择合适的block size可以让模型接近拐点

场景题

您正在为一个多租户环境设计一个推理服务。成千上万的用户会在一个基础模型上微调他们自己的LoRA适配器。来自不同用户的请求会携带不同的LoRA适配器。该服务必须具备高吞吐和低延迟的特性。您将如何设计这个服务栈?请考虑推理引擎的选择、如何管理基础模型权重和大量的LoRA适配器,以及您可能会编写哪些自定义算子来优化性能。

理想回答:

这是一个典型的多租户、高并发、模型动态加载的复杂场景,对推理系统的灵活性和性能都提出了极高的要求。我的设计方案将围绕以下几个核心原则展开:权重共享、动态适配、批处理优化和算子融合。

1. 推理引擎选择:vLLM 我将选择vLLM作为基础推理引擎。主要原因在于其成熟的社区生态和对多LoRA推理的内建支持 。vLLM允许在服务启动时加载基础模型,并在运行时动态加载和切换不同的LoRA适配器,这完美契合了本场景的需求。其PagedAttention和Continuous Batching机制是实现高吞-吐和低延迟的基础保障。  

2. 权重和适配器管理策略:

  • 基础模型权重共享:基础模型的权重是所有用户共享的,体积巨大。它将在服务启动时被加载到所有GPU上,并保持不变。如果模型过大,将采用Tensor Parallelism进行分布式加载 。  

  • 动态LoRA适配器管理:LoRA适配器的权重(A和B矩阵)体积很小(通常只有几MB)。

    • LoRA缓存:我会在CPU内存中设计一个LRU缓存,用于存放最近使用过的LoRA适配器权重。当一个请求到达时,如果其所需的LoRA适配器不在缓存中,系统会从持久化存储(如S3)中加载它。

    • GPU动态加载:当一个携带特定LoRA的请求被调度执行时,vLLM的LoRA管理器会确保该LoRA的权重被加载到GPU显存中。为了避免频繁的CPU到GPU的数据传输,GPU上同样可以设计一个LoRA权重缓存池。

    • PagedLoRA(进阶思路):可以借鉴PagedAttention的思想,将GPU上的LoRA缓存池也用“分页”的方式管理起来。将不同的LoRA权重矩阵也切分成固定大小的块,按需加载到GPU。这可以更精细地管理GPU显存,容纳更多的LoRA适配器。

3. 调度策略优化: 默认的FCFS调度策略在此场景下可能不是最优的。因为频繁切换LoRA适配器会带来权重加载和计算流水线刷新的开销。

  • LoRA感知调度 (LoRA-Aware Scheduling):我会修改或扩展vLLM的调度器,使其具备LoRA感知能力。调度器在组合批次时,会优先将使用相同LoRA适配器的请求打包在一起。这可以最大化每个LoRA权重的复用率,最小化切换开销。可以为每个LoRA维护一个独立的等待队列,调度器轮流或按优先级从这些队列中抽取请求。

4. 自定义算子优化: LoRA的计算核心是在Transformer的每一层中,将原始的权重矩阵W0​替换为W0​+ΔW=W0​+BA。对输入x的计算变为y=xW0​+xBA。这里存在显著的算子融合机会。

  • Fused LoRA Kernel (Triton实现):我会使用Triton编写一个融合的算子,来高效地执行xBA这部分计算。

    • 输入:该算子将接收输入x、LoRA的A矩阵和B矩阵。

    • 融合操作:在一个Kernel内部,完成两个连续的矩阵乘法:

      1. 计算中间结果 z=xB。这个中间结果将保留在高速的SRAM或寄存器中

      2. 立即计算最终结果 out=zA。

    • 优势:这个融合算子避免了将巨大的中间结果xB写入和读出HBM,极大地节省了显存带宽,对于访存密集型的decode阶段尤其重要。相比于PyTorch中两次独立的torch.matmul调用,性能会有显著提升。

  • Fused LoRA Add Kernel:更进一步,可以将LoRA的计算结果与基础模型的计算结果xW0​的相加操作也融合进去。即设计一个算子,输入为x,W0​,B,A,输出为xW0​+xBA。这需要修改模型底层的线性层实现,但能最大程度地减少内存移动。

通过这套结合了vLLM引擎、优化的调度策略和定制化Triton算子的系统设计,我们可以构建一个既能灵活支持海量LoRA适配器,又能保持高吞吐和低延迟的、生产级的多租户推理服务。