vLLM 推理流程分析
1. 概述
本文追踪 vLLM V1 引擎中一个完整的推理 step —— 从请求提交到 token 输出、再到 KV cache 更新的全过程。vLLM 的推理采用连续批处理(continuous batching) 模式,每个 step 不区分 prefill/decode 阶段,而是统一调度所有运行中请求的 token 预算。
2. 推理流程全景
Step 循环
│
├─ 1. Scheduler.schedule()
│ ├─ 遍历 running 请求 → 分配 token 预算 + KV cache block
│ ├─ 遍历 waiting 请求 → prefix cache 查找 + 新 block 分配
│ └─ 输出 SchedulerOutput
│
├─ 2. Executor.execute_model(scheduler_output)
│ └─ GPUModelRunner.execute_model()
│ ├─ _update_states(): 同步 InputBatch 状态
│ ├─ _prepare_inputs(): 构建 token_ids, positions, seq_lens, slot_mapping
│ ├─ _build_attention_metadata(): 构建 block_table + 各 backend 元数据
│ ├─ _preprocess(): 多模态 encoder / input embedding
│ ├─ _model_forward(): model(input_ids, positions) → hidden_states
│ └─ logits = compute_logits(hidden_states[logits_indices])
│
├─ 3. Executor.sample_tokens(grammar_output)
│ └─ Sampler.forward()
│ ├─ logprobs / logits processors / penalties
│ └─ greedy 或 temperature + top-k/top-p 采样
│
├─ 4. Scheduler.update_from_output(scheduler_output, model_output)
│ ├─ 追加输出 token、检查停止条件
│ ├─ 释放已完成请求的 KV cache block
│ └─ 生成 EngineCoreOutput
│
└─ 5. OutputProcessor.process_outputs()
├─ detokenize(token ID → 文本)
├─ logprobs 计算
└─ 返回 RequestOutput(或放入异步队列)
3. 请求提交
3.1 入口
| 入口 | 文件 | 行号 |
|---|---|---|
| 离线推理 | llm.py | L194 |
| API 服务器(异步) | async_llm.py | L524 |
两种入口均经过以下路径:
# 异步路径(API 服务器)
AsyncLLM.generate() # [async_llm.py:524]
→ add_request() # [async_llm.py:280]
→ InputProcessor.process_inputs() # 预处理输入(tokenize 等)
→ OutputProcessor.add_request() # 注册到输出处理器
→ EngineCoreClient.add_request() # 发送到 EngineCore
3.2 EngineCore 接收请求
core.py:1294 — EngineCore._handle_client_request() 分发 ADD 类型请求到 preprocess_add_request()(core.py:794):
preprocess_add_request():
├─ 将 EngineCoreRequest 转换为 Request 对象
├─ 附加 grammar state(结构化输出)
├─ 管理多模态 receiver cache
└─ Scheduler.add_request() → 进入 waiting 队列
请求的数据结构:request.py — Request 对象包含 request_id、prompt_token_ids、sampling_params、num_computed_tokens(已计算的 token 数,初始为 0)、status 等。
4. Scheduler 调度
4.1 调度入口
scheduler.py:329 — Scheduler.schedule() 是每个 step 的入口:
Scheduler.schedule():
Phase 1: 处理 running 请求
Phase 2: 处理 waiting 请求
→ 返回 SchedulerOutput
4.2 Phase 1:Running 请求
scheduler.py:364 — 遍历 self.running 队列中的每个请求:
对每个 running 请求:
├─ 计算 num_new_tokens:
│ 受 token budget 限制 (max_num_batched_tokens / num_seqs)
│ decode 阶段: 1 token(或 spec_decode 的 lookahead 数量)
│ prefill 阶段: 剩余 prompt token 数量
│
├─ kv_cache_manager.allocate_slots(request, num_new_tokens)
│ [kv_cache_manager.py:236]
│ ├─ 释放不再需要的 block(如 sliding window 外的旧 block)
│ ├─ 处理 prefix-cached token(已命中缓存的 block 无需重复计算)
│ └─ 分配新的物理 block
│
└─ 若 block 不足 → 抢占低优先级请求(preemption)
4.3 Phase 2:Waiting 请求
scheduler.py:544 — 遍历 self.waiting 队列:
对每个 waiting 请求:
├─ kv_cache_manager.get_computed_blocks(request)
│ [kv_cache_manager.py:194]
│ └─ coordinator.find_longest_cache_hit()
│ [single_type_kv_cache_manager.py:373]
│ ├─ 计算 token block hashes
│ ├─ 查找 BlockHashToBlockMap
│ └─ 返回命中的 KVCacheBlock 列表(prefix caching)
│
├─ allocate_slots() → 为 prompt token 分配新 block
│
└─ 若 token budget 充足 → 从 waiting 移入 running
4.4 SchedulerOutput
output.py:181 — 调度结果包含:
| 字段 | 说明 |
|---|---|
scheduled_new_reqs | 新加入 running 的请求 |
scheduled_cached_reqs | 命中 prefix cache 的请求 |
num_scheduled_tokens | 每个请求本次调度的 token 数 |
total_num_scheduled_tokens | 总 token 数(用于 CUDA graph 选择) |
scheduled_spec_decode_tokens | 推测解码 token 数 |
num_common_prefix_blocks | 公共前缀 block 数(用于 cascade attention) |
new_block_ids_to_zero | 新分配的 block ID(需清零旧数据) |
finished_req_ids | 需清理的已完成请求 |
5. 模型执行
core.py:428 — EngineCore.step() 的调度-执行-采样三步:
def step(self):
scheduler_output = self.scheduler.schedule() # 调度
future = self.model_executor.execute_model( # 执行
scheduler_output, non_block=True)
grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)
model_output = future.result()
if model_output is None:
model_output = self.model_executor.sample_tokens(grammar_output) # 采样
self.scheduler.update_from_output(scheduler_output, model_output) # 更新
5.1 GPUModelRunner.execute_model()
gpu_model_runner.py:3955 — 模型执行的核心,按顺序完成 6 个子步骤:
5.1.1 _update_states() — 状态同步
_update_states(scheduler_output):
├─ 移除已完成请求 → 从 InputBatch 中删除
├─ 添加新请求 → 创建 CachedRequestState 对象
│ 存储: req_id, prompt_token_ids, num_computed_tokens, block_ids, ...
├─ 更新 block IDs → 反映本次调度新分配的 block
├─ 更新 num_computed_tokens → 反映 prefix cache 命中
├─ 清零新 block (_zero_block_ids) → 防止读到未被写入的 KV cache 位置
├─ 压缩 batch → 填补被移除请求留下的空隙
└─ 可选:重排序 batch → 适应 attention backend 偏好(如 MLA)
核心数据结构是 InputBatch(gpu_model_runner.py),它在 GPU 端维护 block_table、token_ids、positions、seq_lens 等张量。
5.1.2 _prepare_inputs() — 构建输入
_prepare_inputs():
├─ 计算 positions:
│ 对每个 token: position = num_computed_tokens[req] + offset_in_query
│ 这是 vLLM 管理"已计算多少 token"的关键
│
├─ 收集 input_ids:
│ 从 CPU token tensor 按 index 选取 → GPU flat buffer
│
├─ 计算 query_start_loc:
│ [0, tokens_req0, tokens_req0+tokens_req1, ...]
│ FlashAttention 的 varlen 接口需要此信息
│
├─ 计算 seq_lens:
│ seq_len = num_computed_tokens + num_scheduled_tokens
│ (prefill 中 num_scheduled_tokens 可能 > 1,decode 中 = 1)
│
├─ block_table.compute_slot_mapping() [L2093]:
│ 对每个 token:
│ block_number = block_table[req_idx][token_idx // block_size]
│ offset = token_idx % block_size
│ slot = block_number * block_size + offset
│ → 输出 slot_mapping(每个 token 在 KV cache 中的写入位置)
│
└─ _calc_spec_decode_metadata(): 推测解码元数据
5.1.3 _build_attention_metadata() — 构建 attention 元数据
_build_attention_metadata():
├─ 创建 CommonAttentionMetadata [L2280]:
│ ├─ query_start_loc: 每个请求的 query 起始位置
│ ├─ seq_lens: 每个请求的序列总长度
│ ├─ block_table_tensor: [num_seqs, max_blocks] 页表
│ ├─ slot_mapping: 每个 token 的 cache slot
│ ├─ positions: 每个 token 的位置编码
│ ├─ is_prefilling: 每个 token 是否处于 prefill 阶段
│ ├─ max_query_len: 最大 query 长度
│ └─ max_seq_len: 最大序列长度
│
└─ 对每个 KV cache group:
backend_metadata = builder.build(common_prefix_len, common_attn_metadata)
├─ FlashAttentionMetadataBuilder.build() [flash_attn.py:388]
│ ├─ 构建 FlashAttentionMetadata(含 block_table, slot_mapping)
│ ├─ 处理 cascade attention(公共前缀分离)
│ ├─ FA3 AOT scheduler metadata
│ └─ DCP(Decode Context Parallelism)支持
└─ TritonAttentionMetadataBuilder.build() [triton_attn.py:130]
└─ 构建 Triton 专用的元数据
is_prefilling 的状态判断(gpu_model_runner.py:2270:is_prefilling = num_computed_tokens < num_prompt_tokens):
| 条件 | is_prefilling | 含义 |
|---|---|---|
num_computed_tokens < num_prompt_tokens | True | 仍有 prompt token 需要计算(prefill 或 chunked prefill 中间块) |
num_computed_tokens >= num_prompt_tokens | False | prompt 已全部计算完毕,进入 decode 阶段 |
5.1.4 _preprocess() — 预处理
_preprocess():
├─ 多模态模型:
│ ├─ 运行 MM encoder(处理图片/音频等)
│ ├─ 收集多模态 embeddings
│ └─ model.embed_input_ids() → 混合 text + mm embeddings
│
└─ 纯文本模型:
├─ 使用原始 token ID 作为 input_ids
└─ 准备 positions, intermediate_tensors (PP), model_kwargs
5.1.5 _model_forward() — 模型前向传播
def _model_forward(self, input_ids, positions, ...):
hidden_states = self.model(
input_ids=input_ids,
positions=positions,
...
)
return hidden_states
模型内部每一层会调用 Attention.forward()(attention.py:437),详见第 6 节。
5.1.6 logits 提取
# 从最后一个 token 位置提取 hidden states
logits_hidden = hidden_states[logits_indices] # [num_reqs, hidden_size]
# 通过 lm_head 计算 logits
logits = self.model.compute_logits(logits_hidden) # [num_reqs, vocab_size]
logits_indices 是每个请求的最后一个 token 在 batch 中的位置索引。公式为 query_start_loc[1:] - 1(gpu_model_runner.py:2135)。例如 batch 中有 3 个请求,分别调度了 [1, 5, 1] 个 token,query_start_loc = [0, 1, 6, 7],则 logits_indices = [0, 5, 6](取每个请求的最后一个 token)。
5.2 执行结果暂存
结果存储在 ExecuteModelState(gpu_model_runner.py:399):包含 logits、hidden_states、sampling_metadata、spec_decode_metadata 等。execute_model() 返回 None,采样延迟到 sample_tokens() 中执行。
6. Attention 执行细节
6.1 Attention 分发
attention.py:437 — 模型每层的 Attention.forward() 是 KV cache 读写和 attention 计算的分发点:
class Attention.forward(query, key, value, kv_cache, attn_metadata, output):
if impl.forward_includes_kv_cache_update:
# KV cache 写入融合在 attention 内部(如 Triton、某些 FlashInfer 配置)
impl.forward(..., kv_cache, attn_metadata, output)
else:
# 独立的 KV cache 写入 + attention 计算(FlashAttention 默认路径)
unified_kv_cache_update(key, value, kv_cache, slot_mapping) # 先写 KV cache
unified_attention_with_output(query, kv_cache, attn_metadata, output) # 再算 attention
6.2 FlashAttention 路径(默认)
flash_attn.py:667 — FlashAttentionImpl.forward():
def forward(self, query, key, value, kv_cache, attn_metadata, output):
key_cache, value_cache = kv_cache.unbind(dim=0)
# key_cache: [num_blocks, block_size, num_kv_heads, head_size]
# value_cache: [num_blocks, block_size, num_kv_heads, head_size]
output[:] = flash_attn_varlen_func(
query, key_cache, value_cache,
cu_seqlens_q=attn_metadata.query_start_loc,
max_seqlen_q=attn_metadata.max_query_len,
seqlen_k=attn_metadata.seq_lens,
block_table=attn_metadata.block_table, # ← 分页的关键
softmax_scale=self.scale,
causal=True,
)
FlashAttention kernel 内部通过 block_table 做逻辑→物理 block 的间接寻址。
6.3 KV Cache 写入
flash_attn.py:850 — FlashAttentionImpl.do_kv_cache_update():
def do_kv_cache_update(self, key, value, kv_cache, slot_mapping):
key_cache, value_cache = kv_cache # 已解绑的 key/value cache tensors
reshape_and_cache_flash(
key, value, key_cache, value_cache,
slot_mapping, # 每个 token 的目标 slot
kv_cache_dtype, # FP8 量化时做格式转换
k_scale, v_scale,
)
slot_mapping(形状 [total_tokens])的每个元素指定了对应 token 的 key/value 写入 KV cache 中的哪个 slot。
prefill 与 decode 的 slot_mapping 差异:
Prefill (一个请求有 5 个 prompt token):
slot_mapping = [1040, 1041, 1042, 1043, 1044] ← 连续写入
(blocks: [block_65], offsets: [0, 1, 2, 3, 4])
Decode (一个请求生成 1 个 token):
slot_mapping = [2098] ← 单 token 写入
(block: block_131, offset: 2)
7. 采样
7.1 sample_tokens()
gpu_model_runner.py:4319 — GPUModelRunner.sample_tokens():
sample_tokens(grammar_output):
├─ Apply grammar bitmask(结构化输出约束)
├─ _sample(logits, sampling_metadata) [L3481]:
│ ├─ 无推测解码: self.sampler(logits, sampling_metadata)
│ └─ 有推测解码: self.rejection_sampler() → 接受/拒绝 draft tokens
│
└─ 输出 ModelRunnerOutput:
├─ sampled_token_ids: 每个请求采样的 token
├─ logprobs: log probabilities
├─ req_id_to_index: 请求 ID → batch 索引映射
└─ spec_decode_metadata: 推测解码结果
7.2 Sampler.forward()
sampler.py:67 — 核心采样逻辑:
Sampler.forward(logits, sampling_metadata):
Step 1: 计算 logprobs(若需要)
Step 2: logits → float32
Step 3: apply_logits_processors() [L366]:
├─ allowed_token_ids_mask (允许的 token 集合)
├─ bad_words 排除 (禁用词)
├─ min_tokens / logit_bias (非 argmax 不变的处理器)
├─ apply_all_penalties() (重复惩罚/频率惩罚/存在惩罚)
└─ thinking_budget_state_holder (思维链预算)
Step 4: sample() [L238]:
├─ 若 all_greedy (temperature < epsilon):
│ → argmax, 直接返回
└─ 否则:
├─ apply temperature
├─ min_p 处理器
├─ TopKTopPSampler → random sample
└─ 返回采样结果
Step 5: gather_logprobs() [L304]:
├─ torch.topk() → top-k logprobs
└─ 计算采样 token 的 rank
Step 6: 返回 SamplerOutput(sampled_token_ids, logprobs_tensors)
7.3 SamplingMetadata
metadata.py — 包含每个请求的采样参数:temperature、top_p、top_k、随机数生成器 generators、惩罚参数、logits_processors 等。在 _prepare_inputs() 阶段从 SamplingParams 构建。
8. 调度器更新与请求完成
8.1 update_from_output()
scheduler.py:1283 — Scheduler.update_from_output():
update_from_output(scheduler_output, model_output):
对每个被调度的请求:
├─ _update_request_with_output() [L1649]:
│ ├─ 追加 output_token_ids
│ ├─ check_stop(request, max_model_len):
│ │ ├─ 命中 stop token → FINISHED_STOPPED
│ │ ├─ 达到 max_tokens → FINISHED_LENGTH_CAPPED
│ │ ├─ 停止字符串匹配
│ │ └─ pooling 完成
│ └─ 返回 EngineCoreOutput(new_token_ids, finish_reason, logprobs, ...)
│
├─ 若请求停止:
│ ├─ _handle_stopped_request() [L1631]:
│ │ ├─ 可恢复请求(streaming input)→ 移回 waiting
│ │ └─ 不可恢复 → 标记为已完成
│ └─ _free_request() [L1842]:
│ └─ kv_cache_manager.free(request)
│ └─ 递减所有 block 的 ref_cnt
│ └─ ref_cnt == 0 → 归还到 FreeKVCacheBlockQueue
│
└─ 发布 KV cache events
8.2 请求状态流转
request.py:315 — RequestStatus 枚举:
WAITING → RUNNING → (step 循环)
→ PREEMPTED(被抢占,回 waiting)
→ FINISHED_STOPPED
→ FINISHED_LENGTH_CAPPED
→ FINISHED_ABORTED
→ FINISHED_IGNORED
→ FINISHED_ERROR
9. 输出处理
9.1 OutputProcessor
output_processor.py:576 — OutputProcessor.process_outputs():
process_outputs(engine_core_outputs):
├─ 计算迭代统计
├─ IncrementalDetokenizer.update() [L639]:
│ └─ token ID → 文本(增量解码,保留状态)
│ └─ 检测 stop strings
├─ LogprobsProcessor.update_from_output() [L648]:
│ └─ 计算 sample logprobs / prompt logprobs
├─ req_state.make_request_output() [L651]:
│ └─ 构建 RequestOutput(文本, token_ids, logprobs, finish_reason)
└─ 分发:
├─ AsyncLLM: put 到 per-request RequestOutputCollector 队列 [L663]
└─ LLMEngine: 返回 List[RequestOutput] [L666]
9.2 流式输出
对于 streaming 请求,RequestOutputCollector 是一个 AsyncQueue。API 服务器的 /v1/completions 或 /v1/chat/completions endpoint 以 SSE (Server-Sent Events) 格式流式返回每个 RequestOutput。
10. KV Cache 在推理中的生命周期
10.1 首次 Prefill
Prompt token IDs: [101, 2023, 3186, 2877, 13]
block_size = 4
Step 1: allocate_slots()
├─ 分配 block_0(4 个 slot)→ tokens [101, 2023, 3186, 2877]
└─ 分配 block_1(1 个 slot)→ token [13, -, -, -]
Step 2: 构建 slot_mapping
[0, 1, 2, 3, 4] → (block_0的4个slot, block_1的1个slot)
Step 3: Attention forward
├─ Q = 所有 5 个 token 的 query
├─ K, V = 所有 5 个 token 的 key/value
├─ 写入 KV cache(通过 slot_mapping)
└─ causal mask(每个 token 只能看到自己及之前的 token)
10.2 Decode 迭代
Decode step N:
Step 1: allocate_slots()
└─ 若当前 block 仍有空间 → 无需新分配
否则 → 分配新 block
Step 2: slot_mapping = [block_id * block_size + offset]
└─ 只有一个 slot
Step 3: Attention forward
├─ Q = 只有最新 token 的 query(单 token)
├─ K, V = 只有最新 token 的 key/value
├─ 写入 KV cache(通过 slot_mapping,写到对应 slot)
└─ KV cache 中已有之前所有 token 的 K/V
→ block_table 告诉 kernel 如何找到它们
10.3 Prefix Caching 复用
请求 B 与请求 A 共享前缀 [101, 2023, 3186, 2877](4 个 token,block_size=4)
请求 A:
block_0 (hash=0xABCD) → 存储这 4 个 token 的 KV
请求 B:
get_computed_blocks() → block_0 命中!
├─ touch(block_0): ref_cnt = 1 → 2
├─ num_computed_tokens = 4(跳过这 4 个 token 的计算)
└─ 只需从 block_1 开始分配
11. 完整 Step 调用链
EngineCore.step() [core.py:428]
│
├─ Scheduler.schedule() [scheduler.py:329]
│ ├─ Phase 1: 遍历 running 请求 [scheduler.py:364]
│ │ └─ kv_cache_manager.allocate_slots() [kv_cache_manager.py:236]
│ │ ├─ remove_skipped_blocks() [single_type_kv_cache_manager.py:420]
│ │ ├─ allocate_new_computed_blocks() [single_type_kv_cache_manager.py:170]
│ │ └─ allocate_new_blocks() [single_type_kv_cache_manager.py:243]
│ ├─ Phase 2: 遍历 waiting 请求 [scheduler.py:544]
│ │ ├─ get_computed_blocks() [kv_cache_manager.py:194]
│ │ │ └─ coordinator.find_longest_cache_hit() [single_type_kv_cache_manager.py:373]
│ │ └─ allocate_slots()
│ └─ 返回 SchedulerOutput
│
├─ Executor.execute_model(scheduler_output) [core.py:430]
│ └─ GPUModelRunner.execute_model() [gpu_model_runner.py:3955]
│ ├─ _update_states() [gpu_model_runner.py:1116]
│ │ ├─ 移除已完成请求
│ │ ├─ 添加新请求 → CachedRequestState
│ │ ├─ 更新 block IDs / num_computed_tokens
│ │ └─ 压缩 batch
│ ├─ _prepare_inputs() [gpu_model_runner.py:1863]
│ │ ├─ 计算 positions
│ │ ├─ 收集 token_ids [gpu_model_runner.py:1950]
│ │ ├─ 计算 query_start_loc / seq_lens
│ │ └─ block_table.compute_slot_mapping() [gpu_model_runner.py:2093]
│ ├─ _build_attention_metadata() [gpu_model_runner.py:2183]
│ │ ├─ CommonAttentionMetadata [gpu_model_runner.py:2280]
│ │ └─ 各 backend MetadataBuilder.build()
│ │ ├─ FlashAttentionMetadataBuilder [flash_attn.py:388]
│ │ └─ TritonAttentionMetadataBuilder [triton_attn.py:130]
│ ├─ _preprocess() [gpu_model_runner.py:3364]
│ │ └─ MM encoder / input embedding
│ ├─ _model_forward() [gpu_model_runner.py:3668]
│ │ └─ model(input_ids, positions, ...)
│ │ └─ 每层 Attention.forward() [attention.py:437]
│ │ ├─ KV cache 写入 (unified_kv_cache_update)
│ │ │ └─ reshape_and_cache_flash() [cache_kernels.cu:304]
│ │ └─ Attention 计算
│ │ └─ flash_attn_varlen_func() [flash_attn.py:667]
│ │ └─ 内部通过 block_table 寻址 KV cache
│ └─ logits = compute_logits(hidden_states[logits_indices])
│
├─ Executor.sample_tokens(grammar_output) [core.py:434]
│ └─ GPUModelRunner.sample_tokens() [gpu_model_runner.py:4319]
│ └─ Sampler.forward() [sampler.py:67]
│ ├─ apply_logits_processors() [sampler.py:366]
│ ├─ sample() (greedy / top-k-top-p) [sampler.py:238]
│ └─ gather_logprobs() [sampler.py:304]
│
├─ Scheduler.update_from_output(output) [scheduler.py:1283]
│ ├─ _update_request_with_output() [scheduler.py:1649]
│ │ ├─ 追加 output_token_ids
│ │ └─ check_stop() → finish_reason
│ ├─ _handle_stopped_request() [scheduler.py:1631]
│ ├─ _free_request() → kv_cache_manager.free() [scheduler.py:1842]
│ └─ 返回 EngineCoreOutputs
│
└─ OutputProcessor.process_outputs(outputs) [output_processor.py:576]
├─ IncrementalDetokenizer.update() [output_processor.py:639]
├─ LogprobsProcessor.update_from_output() [output_processor.py:648]
└─ RequestOutput → 返回或放入异步队列
12. 关键文件索引
| 组件 | 文件 | 核心行号 |
|---|---|---|
| LLM 入口 | llm.py | L194 |
| AsyncLLM | async_llm.py | L280, L524 |
| EngineCore | core.py | L428 (step), L794 (add_request), L1294 |
| Scheduler | scheduler.py | L329 (schedule), L1283 (update), L1649 (_update_request) |
| KVCacheManager | kv_cache_manager.py | L194 (get_computed_blocks), L236 (allocate_slots), L429 (free) |
| GPUModelRunner | gpu_model_runner.py | L1116 (_update_states), L1863 (_prepare_inputs), L2183 (_build_attention_metadata), L3668 (_model_forward), L3955 (execute_model), L4319 (sample_tokens) |
| Attention 层 | attention.py | L437 |
| FlashAttention 后端 | flash_attn.py | L388 (MetadataBuilder), L667 (forward), L850 (do_kv_cache_update) |
| Sampler | sampler.py | L67 (forward), L238 (sample), L366 (apply_logits_processors) |
| OutputProcessor | output_processor.py | L576 |
| 请求数据结构 | request.py | RequestStatus L315 |