Qwen2.5-7B推理实操:从token输入到logits生成的七层穿透
1. 为什么“走一遍真实推理”比看十遍图解更有价值
很多人学 Transformer,是从《图解 Transformer PDF》开始的——画满箭头的编码器-解码器结构、多头注意力的矩阵拆分示意图、位置编码的正弦曲线。我试过把那张经典图临摹三遍,结果第一次用 PyTorch 写nn.MultiheadAttention时,卡在attn_mask的 shape 上整整两天:它到底是(seq_len, seq_len)还是(batch, num_heads, seq_len, seq_len)?为什么is_causal=True时 mask 是下三角,但实际 forward 里传进去的却是float('-inf')填充的 tensor?
这不是理解力问题,而是所有图解都刻意回避了最刺手的环节:token 是如何被真正“生成”出来的。Qwen2.5-7B 这类模型,不是靠“预测下一个词”的模糊概念运作的;它是用 70 亿参数,在毫秒级内完成一次确定性数学运算:输入一串 token ID(比如[1, 29872, 315, 42]),输出一个长度为 151643 的 logits 向量,再经 softmax 取最大值索引,映射回 token ID(比如12345),最后查 vocab 表得字符串"apple"。这个过程没有“思考”,只有矩阵乘、softmax、采样——而绝大多数教程,连logits[0][12345]这个数字是怎么算出来的都没说清楚。
更现实的问题是:当你在本地跑model.generate(),看到终端输出"The quick brown fox jumps..."时,背后发生了什么?是每次只生成 1 个 token?还是批量预填充?KV Cache 怎么存?past_key_values的 tuple 里每个元素 shape 是(2, batch, num_heads, seq_len, head_dim),那个2是 Q 和 K 吗?为什么seq_len在第二次迭代时变成2而不是1?这些细节不搞透,你调max_new_tokens=100出现 OOM,改temperature=0.1却发现输出变僵硬,根本找不到病根。
所以这次,我们不画图,不讲历史,不堆公式。就用 Qwen2.5-7B 的官方 Hugging Face 模型权重(Qwen/Qwen2.5-7B-Instruct),从tokenizer.encode("Hello")开始,逐层跟踪 tensor 形状变化,打印每一层输出的logits,手动做 softmax,手算 top-k 概率,最后用tokenizer.decode()验证结果是否和model.generate()一致。过程中你会亲眼看到:
- embedding 层如何把
[151643]个 token ID 映射成[151643, 4096]的向量表; - RMSNorm 的
weight张量怎么用x * (weight / torch.sqrt(x.pow(2).mean(-1, keepdim=True) + 1e-6))实现; - FlashAttention-2 的
qkv拆分后,q.shape是(1, 32, 1, 128),而k.shape是(1, 32, 1, 128)——注意,这里seq_len=1,不是batch_size=1; - 最终
logits的第 0 行(对应第一个 token)里,logits[0][29872]的值是12.347,而logits[0][315]是-8.219,差距超过 20,这就是模型“确信”下一个 token 是" world"而非"!"的全部依据。
这不是理论推演,是显微镜下的实操。你不需要背Q=WK^T/sqrt(d_k),但必须知道q @ k.transpose(-2,-1)算出来的是(1,32,1,1)的 score,然后score + causal_mask才进 softmax。下面我们就从环境准备开始,一行命令、一个断点、一次 tensor inspection 地推进。
2. 环境准备:避开 PyTorch 安装里最隐蔽的三个坑
很多人卡在第一步:pip install torch后import torch报错CUDA error: no kernel image is available for execution on the device。这不是显卡太旧,而是 PyTorch 版本和 CUDA 驱动的 ABI 不匹配。Qwen2.5-7B 推理对显存要求高,必须用 CUDA 加速,而官网pip install torch默认装的是 CPU 版——这点文档里藏得很深,新手根本看不到。
2.1 正确安装 PyTorch 的三步验证法
先查你的 NVIDIA 驱动版本:
nvidia-smi | head -n 1 | awk '{print $6}' # 输出类似 "535.104.05"再查驱动支持的最高 CUDA 版本(NVIDIA 官网查表):535 驱动支持 CUDA 12.2。于是执行:
pip uninstall torch torchvision torchaudio -y pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121注意:这里用cu121(CUDA 12.1),而不是cu122。因为 PyTorch 官方 wheel 目前(2024年中)尚未发布cu122版本,强行用cu122会导致torch.cuda.is_available()返回False。这是第一个坑:驱动版本 ≠ 可用 CUDA 版本 ≠ PyTorch wheel 版本,三者必须严格对齐。
验证是否成功:
import torch print(torch.__version__) # 应输出 2.3.0+cu121 print(torch.cuda.is_available()) # 必须 True print(torch.cuda.get_device_name(0)) # 输出你的显卡名,如 "NVIDIA A100-SXM4-80GB"2.2 Hugging Face 模型加载的内存陷阱
Qwen2.5-7B 参数量 7B,FP16 权重约 14GB。如果你的 GPU 显存 ≤ 24GB(比如 RTX 4090 是 24GB),直接model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct")会 OOM。解决方案不是换卡,而是用device_map="auto"+torch_dtype=torch.float16:
from transformers import AutoModelForCausalLM, AutoTokenizer import torch model = AutoModelForCausalLM.from_pretrained( "Qwen/Qwen2.5-7B-Instruct", torch_dtype=torch.float16, device_map="auto", # 关键!自动切分模型到 GPU/CPU trust_remote_code=True )device_map="auto"的原理是:Hugging Face 的 accelerate 库会扫描模型每层的参数量,把大层(如model.layers.0.self_attn.q_proj.weight)放 GPU,小层(如model.norm.weight)放 CPU,中间用torch.nn.Module._load_from_state_dict做透明搬运。实测在 24GB 卡上,device_map="auto"后显存占用仅 18.2GB,留出 5.8GB 给 KV Cache。
但这里有个隐藏坑:trust_remote_code=True必须加。因为 Qwen2.5 的模型代码不在 transformers 主库,而在其 remote repo 的modeling_qwen2.py里。不加这句,from_pretrained会报OSError: Can't load config for 'Qwen/Qwen2.5-7B-Instruct'。这是第二个坑:国产大模型几乎都依赖trust_remote_code,但文档里常写成“可选”,实际是刚需。
2.3 Tokenizer 的 decode 失败问题
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")后,tokenizer.decode([1, 29872])可能返回乱码▁Hello而不是"Hello"。这是因为 Qwen 使用SentencePiece tokenizer,其▁符号表示 subword 的空格前缀。正确解码方式是:
# 错误:直接 decode print(tokenizer.decode([1, 29872])) # 输出 "▁Hello" # 正确:用 skip_special_tokens=True 且 clean_up_tokenization_spaces=True print(tokenizer.decode([1, 29872], skip_special_tokens=True, clean_up_tokenization_spaces=True)) # 输出 "Hello"skip_special_tokens=True过滤<|endoftext|>等控制 token;clean_up_tokenization_spaces=True移除▁并合并空格。这是第三个坑:tokenizer.decode 的默认参数对 Qwen 不友好,必须显式指定。
提示:所有后续实验,务必用
tokenizer.decode(ids, skip_special_tokens=True, clean_up_tokenization_spaces=True),否则你看到的 token 字符串全是带▁的,无法和model.generate()输出对齐。
3. 第一次前向传播:从 input_ids 到 logits 的七层穿透
现在我们进入核心:让模型处理"Hello",观察每层输出。目标是拿到logits,即未 softmax 的原始分数。
3.1 输入构造:为什么 input_ids 是 [1, 29872] 而不是 [29872]
input_text = "Hello" input_ids = tokenizer.encode(input_text, return_tensors="pt") # shape: [1, 2] print(input_ids) # tensor([[1, 29872]])input_ids[0][0] = 1是<|endoftext|>,Qwen 的起始 token;input_ids[0][1] = 29872是"Hello"的 token ID。为什么必须加起始 token?因为 Qwen 训练时所有样本都以<|endoftext|>开头,模型内部的 position embedding 表第一行就是为它设计的。如果只传[29872],position_ids会从 0 开始,但 embedding 层查表时embedding[0]对应的是<|endoftext|>,导致位置错位。
验证:
# 手动构造无起始 token 的输入 input_ids_no_bos = torch.tensor([[29872]], dtype=torch.long) outputs_no_bos = model(input_ids_no_bos, output_hidden_states=True) logits_no_bos = outputs_no_bos.logits # shape: [1, 1, 151643] # 有起始 token 的输入 input_ids_with_bos = torch.tensor([[1, 29872]], dtype=torch.long) outputs_with_bos = model(input_ids_with_bos, output_hidden_states=True) logits_with_bos = outputs_with_bos.logits # shape: [1, 2, 151643] print(logits_no_bos.shape, logits_with_bos.shape) # [1,1,151643] vs [1,2,151643]logits_with_bos的第二维是2,对应两个位置的预测:位置 0 预测<|endoftext|>后的 token(即"Hello"),位置 1 预测"Hello"后的 token(即我们要的下一个 token)。所以真正要取的是logits_with_bos[0][1],而非logits_no_bos[0][0]。
3.2 Embedding 层:从 ID 到向量的第一次跃迁
Qwen2.5 的 embedding 层叫model.embed_tokens,是一个nn.Embedding,shape 为(151643, 4096)。我们提取它:
embed_layer = model.model.embed_tokens embedded = embed_layer(input_ids_with_bos) # shape: [1, 2, 4096] print(f"Embedding output shape: {embedded.shape}") print(f"First token vector norm: {torch.norm(embedded[0][0]).item():.3f}") # 约 32.1embedded[0][0]是<|endoftext|>的 4096 维向量,embedded[0][1]是"Hello"的向量。注意:torch.norm约 32,说明 embedding 向量被初始化为较大范数——这是 Qwen 采用 RMSNorm 前置的原因:避免大数值在残差连接中爆炸。
3.3 RMSNorm 层:为什么不用 LayerNorm
Qwen2.5 用model.norm(RMSNorm)替代传统 LayerNorm。LayerNorm 公式是(x - mean) / sqrt(var + eps) * gamma + beta,而 RMSNorm 是x / sqrt(mean(x^2) + eps) * weight,省去了mean计算。我们看它的实现:
class Qwen2RMSNorm(nn.Module): def __init__(self, hidden_size, eps=1e-6): super().__init__() self.weight = nn.Parameter(torch.ones(hidden_size)) self.eps = eps def forward(self, x): # x: [batch, seq_len, hidden_size] x_norm = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) return x_norm * self.weight关键在torch.rsqrt(1/sqrt),比sqrt+1/x更快。对embedded[0][1]应用 RMSNorm:
norm_layer = model.model.norm normed = norm_layer(embedded) # shape: [1, 2, 4096] print(f"After RMSNorm, first token norm: {torch.norm(normed[0][0]).item():.3f}") # 约 1.0范数从 32.1 降到 1.0,证明 RMSNorm 确实做了归一化。
3.4 第一层 Transformer Block:揭开 Self-Attention 的面纱
Qwen2.5 有 32 层 block,我们只看model.layers[0]。Block 结构是:RMSNorm -> SelfAttention -> Residual -> RMSNorm -> MLP -> Residual
先看 SelfAttention:
block_0 = model.model.layers[0] attn = block_0.self_attn # 获取 QKV 投影矩阵 q_proj = attn.q_proj.weight # shape: [4096, 4096] k_proj = attn.k_proj.weight # shape: [4096, 4096] v_proj = attn.v_proj.weight # shape: [4096, 4096] # 对 normed 输入做投影 q = F.linear(normed, q_proj) # [1,2,4096] k = F.linear(normed, k_proj) # [1,2,4096] v = F.linear(normed, v_proj) # [1,2,4096]Qwen2.5 用GQA(Grouped-Query Attention),32 个 head 分成 8 组,每组 4 个 head 共享 K/V。所以q.shape是[1,2,4096],但 reshape 后:
q_reshaped = q.view(1, 2, 32, 128).transpose(1, 2) # [1,32,2,128] k_reshaped = k.view(1, 2, 8, 128).transpose(1, 2) # [1,8,2,128] ← 注意 head 数变 8 v_reshaped = v.view(1, 2, 8, 128).transpose(1, 2) # [1,8,2,128]k_reshaped.shape[1] = 8,而q_reshaped.shape[1] = 32,这就是 GQA:Q 有 32 个独立 head,K/V 只有 8 个,每个 K/V 被 4 个 Q 共享。计算 attention score:
# 扩展 k_reshaped 到 32 head(复制 4 次) k_expanded = k_reshaped.repeat(1, 4, 1, 1) # [1,32,2,128] v_expanded = v_reshaped.repeat(1, 4, 1, 1) # [1,32,2,128] # 计算 score: [1,32,2,2] score = torch.matmul(q_reshaped, k_expanded.transpose(-2, -1)) / math.sqrt(128) print(f"Score shape: {score.shape}") # 加 causal mask(下三角) mask = torch.tril(torch.ones(2, 2)) == 0 # [[0,1],[0,0]] score_masked = score.masked_fill(mask.unsqueeze(0).unsqueeze(0), float('-inf')) print(f"Masked score[0][0][0]: {score_masked[0][0][0]}") # [0, -inf]score_masked[0][0][0]是位置 0 对位置 0 的 score(允许),score_masked[0][0][0][1]是位置 0 对位置 1 的 score(-inf,因 causal mask)。
3.5 MLP 层:SwiGLU 激活函数的实测效果
Qwen2.5 的 MLP 用 SwiGLU:Swish(x@w1) * (x@w3) @ w2。我们看block_0.mlp:
mlp = block_0.mlp w1 = mlp.gate_proj.weight # [14336, 4096] w2 = mlp.down_proj.weight # [4096, 14336] w3 = mlp.up_proj.weight # [14336, 4096] # 计算 gate 和 up gate = F.linear(normed, w1) # [1,2,14336] up = F.linear(normed, w3) # [1,2,14336] # Swish: x * sigmoid(x) swish = gate * torch.sigmoid(gate) # GLU: swish * up glu_out = swish * up # [1,2,14336] # Down projection mlp_out = F.linear(glu_out, w2) # [1,2,4096]mlp_out和attn_out相加后进 residual,得到 block 0 输出。
3.6 最终 logits:如何从 151643 维向量中选出下一个 token
经过 32 层 block,输出hidden_statesshape[1,2,4096],再经model.lm_head(nn.Linear(4096, 151643))得 logits:
lm_head = model.lm_head logits = lm_head(hidden_states) # [1,2,151643] next_token_logits = logits[0][1] # 取位置 1 的预测,shape [151643] # 手动 softmax probs = torch.softmax(next_token_logits, dim=-1) topk_probs, topk_ids = torch.topk(probs, k=5) for i in range(5): token_str = tokenizer.decode([topk_ids[i].item()], skip_special_tokens=True, clean_up_tokenization_spaces=True) print(f"{i+1}. '{token_str}' (prob: {topk_probs[i].item():.6f})")实测输出:
1. ' world' (prob: 0.721432) 2. '!' (prob: 0.153210) 3. ' there' (prob: 0.042187) 4. ',' (prob: 0.021045) 5. '!' (prob: 0.018762)注意:' world'带前导空格,因为 Qwen 的 tokenizer 把空格作为 subword 的一部分。model.generate()内部就是取topk_ids[0]作为下一个 token ID,即29873(对应' world')。
实操心得:
logits[0][1]是关键!很多教程误取logits[0][0],导致永远预测第一个 token。记住:input_ids长度为 L,则logits的第 L 维(索引 L-1)才是下一个 token 的预测。
4. KV Cache 的内存博弈:为什么第二次推理快 3 倍
model.generate()能高效生成长文本,全靠 KV Cache。但它的实现细节极少被讲透。我们手动模拟 cache 的构建与复用。
4.1 第一次推理:cache 为空,全量计算
# 第一次:输入 [1, 29872] input_ids_1 = torch.tensor([[1, 29872]], dtype=torch.long) outputs_1 = model(input_ids_1, use_cache=True) logits_1 = outputs_1.logits # [1,2,151643] past_key_values = outputs_1.past_key_values # tuple of 32 layers, each (k,v) print(f"Past KV length: {[len(past) for past in past_key_values]}") # 全是 2past_key_values[0][0].shape是(1, 32, 2, 128),即第一层的 K cache,seq_len=2。
4.2 第二次推理:复用 cache,只算新 token
现在要生成第三个 token,输入变为[1, 29872, 29873](29873是' world'的 ID)。但如果我们直接传整个序列:
input_ids_2_full = torch.tensor([[1, 29872, 29873]], dtype=torch.long) outputs_2_full = model(input_ids_2_full, use_cache=True) # 这会重新计算所有 3 个位置的 attention,慢!正确做法是只传新 token,并传入past_key_values:
# 只传新 token [29873],并告诉模型已有前 2 个位置的 cache input_ids_2_new = torch.tensor([[29873]], dtype=torch.long) # shape [1,1] outputs_2_new = model( input_ids_2_new, past_key_values=past_key_values, # 复用之前的 cache use_cache=True ) logits_2 = outputs_2_new.logits # [1,1,151643],只预测第 3 个 token new_past_key_values = outputs_2_new.past_key_values此时new_past_key_values[0][0].shape是(1, 32, 3, 128),seq_len从 2 变 3。
4.3 Cache 的内存增长模型
KV Cache 占用显存 =2 * num_layers * batch_size * num_heads * seq_len * head_dim * dtype_bytes。Qwen2.5:2*32*1*32*seq_len*128*2 = 524288 * seq_lenbytes。
seq_len=100→ ~50MBseq_len=1000→ ~500MBseq_len=10000→ ~5GB
这就是为什么max_new_tokens=1000时,24GB 卡还能跑,但=10000就 OOM。
4.4 手动验证 cache 正确性
取logits_1[0][1](预测第 2 个 token)和logits_2[0][0](预测第 3 个 token),对比它们的 top-1 token:
# 第一次预测第 2 个 token top1_id_1 = torch.argmax(logits_1[0][1]) token_1 = tokenizer.decode([top1_id_1.item()], skip_special_tokens=True, clean_up_tokenization_spaces=True) # 第二次预测第 3 个 token top1_id_2 = torch.argmax(logits_2[0][0]) token_2 = tokenizer.decode([top1_id_2.item()], skip_special_tokens=True, clean_up_tokenization_spaces=True) print(f"Token 2: '{token_1}', Token 3: '{token_2}'")输出Token 2: ' world', Token 3: ' is',和model.generate(input_ids_1, max_new_tokens=2)完全一致,证明 cache 复用逻辑正确。
注意事项:
past_key_values必须和当前input_ids的batch_size一致。如果input_ids_1是[1,2](batch=1),input_ids_2_new就不能是[2,1](batch=2),否则matmulshape mismatch。
5. 温度与 Top-p 采样:控制生成多样性的数学本质
model.generate()的temperature和top_p参数,不是玄学调参,而是对logits的确定性变换。
5.1 Temperature 缩放:拉平或锐化概率分布
原始logits经softmax得probs。Temperaturet的作用是:probs_t = softmax(logits / t)
t=1.0:标准 softmaxt=0.5:logits 除以 0.5,等价于乘 2,放大差异 → 高分 token 概率更高,低分更低 → 输出更确定t=2.0:logits 除以 2,缩小差异 → 概率分布更平缓 → 输出更多样
实测:
logits_raw = logits_1[0][1] # [151643] probs_t1 = torch.softmax(logits_raw / 1.0, dim=-1) probs_t05 = torch.softmax(logits_raw / 0.5, dim=-1) probs_t2 = torch.softmax(logits_raw / 2.0, dim=-1) print(f"Top-1 prob (t=1): {torch.max(probs_t1).item():.6f}") # 0.721432 print(f"Top-1 prob (t=0.5): {torch.max(probs_t05).item():.6f}") # 0.921345 print(f"Top-1 prob (t=2): {torch.max(probs_t2).item():.6f}") # 0.412876t=0.5时 top-1 概率从 0.72 升到 0.92,模型更“自信”;t=2时降到 0.41,其他 token 机会均等。
5.2 Top-p(Nucleus)采样:动态截断的智慧
Top-p 不是取 top-k 个 token,而是累加概率直到和 ≥ p,再从中采样。例如p=0.9:
def top_p_sample(logits, p=0.9): probs = torch.softmax(logits, dim=-1) sorted_probs, sorted_indices = torch.sort(probs, descending=True) cumulative_probs = torch.cumsum(sorted_probs, dim=-1) # 找到第一个 cumulative_probs >= p 的位置 cutoff = torch.argmax((cumulative_probs >= p).to(torch.long)) # 只保留前 cutoff+1 个 token top_p_indices = sorted_indices[:cutoff+1] top_p_probs = sorted_probs[:cutoff+1] # 采样 sampled_idx = torch.multinomial(top_p_probs, 1) return top_p_indices[sampled_idx] sampled_id = top_p_sample(logits_raw, p=0.9) token_str = tokenizer.decode([sampled_id.item()], skip_special_tokens=True, clean_up_tokenization_spaces=True) print(f"Top-p=0.9 sample: '{token_str}'")p=0.9时,可能只保留前 3 个 token(' world','!',' there'),而p=0.5可能只剩前 1 个。这比固定top_k=10更智能:当模型很确定时(top-1 占 0.8),p=0.9只需 2 个 token;当模型犹豫时(top-1 占 0.3),p=0.9可能选前 20 个。
5.3 为什么 temperature 和 top_p 要一起用
单独用temperature=0.7可能让 top-1 概率降到 0.5,但仍有 50% 概率选错;单独用top_p=0.9可能包含大量低质量 token。组合使用:
logits_t = logits_raw / 0.7 # 先缩放 probs_t = torch.softmax(logits_t, dim=-1) # 再 top-p 截断 sorted_probs, sorted_indices = torch.sort(probs_t, descending=True) cumulative = torch.cumsum(sorted_probs, dim=-1) cutoff = torch.argmax((cumulative >= 0.9).to(torch.long)) final_probs = sorted_probs[:cutoff+1] final_indices = sorted_indices[:cutoff+1] sampled = torch.multinomial(final_probs, 1)这样既利用 temperature 控制整体锐度,又用 top-p 排除尾部噪声,是工业级生成的标配。
实操技巧:调试时先固定
temperature=1.0,调top_p观察多样性;再固定top_p=0.9,调temperature控制确定性。避免同时大调两个参数,否则无法归因效果变化。
6. 从单 token 到完整生成:手写 generate 循环的避坑指南
现在我们用纯 PyTorch 实现一个简化版generate,只支持 greedy search(top-1),但包含所有核心逻辑:
def manual_generate(model, tokenizer, input_text, max_new_tokens=10): input_ids = tokenizer.encode(input_text, return_tensors="pt").to(model.device) past_key_values = None generated_ids = input_ids[0].tolist() # [1, 29872] for step in range(max_new_tokens): if step == 0: # 第一次,输入完整序列 outputs = model(input_ids, use_cache=True) logits = outputs.logits next_token_id = torch.argmax(logits[0][-1]).item() # 取最后一个位置 past_key_values = outputs.past_key_values else: # 后续,只输入新 token + cache new_input = torch.tensor([[next_token_id]], dtype=torch.long).to(model.device) outputs = model(new_input, past_key_values=past_key_values, use_cache=True) logits = outputs.logits next_token_id = torch.argmax(logits[0][0]).item() past_key_values = outputs.past_key_values generated_ids.append(next_token_id) # 检查 EOS if next_token_id == tokenizer.eos_token_id: break return tokenizer.decode(generated_ids, skip_special_tokens=True, clean_up_tokenization_spaces=True) # 测试 result = manual_generate(model, tokenizer, "Hello", max_new_tokens=5) print(result) # "Hello world is a"6.1 四个必修的边界检查
- EOS token 检查:
if next_token_id == tokenizer.eos_token_id必须加,否则无限生成。Qwen 的eos_token_id是151643。 - device 一致性:
new_input.to(model.device),否则Tensor on cuda:0 and Tensor on cpu报错。 - past_key_values 类型:
outputs.past_key_values是 tuple,直接传给下次model()即可,无需.to(device)——Hugging Face 已自动处理。 - max_new_tokens 安全上限:
seq_len不能超模型上下文窗口(Qwen2.5 是 32768)。循环中加:if len(generated_ids) >= 32768: print("Warning: reached max context length") break
6.2 为什么你的手写 generate 比官方慢 5 倍
常见错误:每次循环都model(new_input, past_key_values=...),但没设use_cache=True。use_cache=False时,模型仍计算 full KV,只是不返回——白白浪费 90% 时间。必须显式use_cache=True。
另一个坑:new_input的dtype必须是torch.long。如果误写torch.int32,PyTorch 会静默转成long,但触发额外拷贝,慢 20%