混合精度訓練(Mixed Precision Training)是一種通過結合單精度(FP32)和半精度(FP16/FP8)計算來加速訓練、減少顯存占用的技術。它在保持模型精度的同時,通常能帶來 2-3 倍的訓練速度提升,并減少約 50% 的顯存使用,是平衡訓練效率與數值穩定性的核心技術,尤其在大模型訓練中不可或缺。
以下從GradScaler 底層邏輯、避坑技巧(含 NaN 解決方案)、PyTorch Lightning 實戰三個維度展開,結合實戰細節提供更深入的最佳實踐。
一、核心思想
混合精度訓練的核心原理
傳統訓練使用 32 位浮點數(FP32)存儲參數和計算梯度,但研究發現:
- 模型參數和激活值對精度要求較高(需 FP32)
- 梯度計算和反向傳播對精度要求較低(可用 FP16)
混合精度訓練的核心邏輯:
- 用 FP16 執行大部分計算(前向 / 反向傳播),加速運算并減少顯存
- 用 FP32 保存模型參數和優化器狀態,確保數值穩定性
- 通過 “損失縮放”(Loss Scaling)解決 FP16 梯度下溢問題
二、GradScaler 縮放機制:從原理到細節
GradScaler 的核心是解決 FP16 梯度下溢問題,但縮放邏輯并非簡單的固定倍數,而是動態自適應的。理解其底層邏輯能更好地控制訓練穩定性。
-
核心流程
- 縮放損失:將損失乘以一個縮放因子(初始值通常為 2^16),使梯度按比例放大,避免下溢;
- 反向傳播:用縮放后的損失計算梯度(FP16);
- 梯度修正:將梯度除以縮放因子,恢復真實梯度值;
- 參數更新:用修正后的梯度更新參數(FP32)。
-
縮放因子的動態調整邏輯
GradScaler 維護一個初始縮放因子(默認2^16),并根據每次迭代的梯度是否溢出動態調整:- 無溢出:若連續多次(默認2000次)未溢出,縮放因子乘以growth_factor(默認1.0001),逐步放大以更充分利用 FP16 范圍;
- 有溢出:縮放因子乘以backoff_factor(默認0.5),并跳過本次參數更新(避免錯誤梯度影響模型)
# 查看當前縮放因子(調試用) print(scaler.get_scale()) # 輸出當前縮放因子值
-
PyTorch 原生混合精度訓練實現
import torch
import torch.nn as nn
from torch.optim import Adam# 1. 初始化模型、損失函數、優化器
model = nn.Linear(10, 2).cuda()
criterion = nn.CrossEntropyLoss()
optimizer = Adam(model.parameters(), lr=1e-3)# 2. 初始化GradScaler(關鍵)
scaler = torch.cuda.amp.GradScaler()# 3. 訓練循環
for inputs, labels in train_loader:inputs, labels = inputs.cuda(), labels.cuda()# 清零梯度optimizer.zero_grad()# 前向傳播:用FP16計算(torch.cuda.amp.autocast上下文)with torch.cuda.amp.autocast(): # 自動將FP32操作轉為FP16(支持的操作)outputs = model(inputs)loss = criterion(outputs, labels) # 損失仍以FP32計算(更穩定)# 反向傳播:縮放損失,計算梯度scaler.scale(loss).backward() # 縮放損失,避免梯度下溢# 梯度裁剪(可選,防止梯度爆炸)scaler.unscale_(optimizer) # 先將梯度恢復(除以縮放因子)torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 裁剪梯度# 更新參數:用修正后的梯度scaler.step(optimizer) # 僅當梯度未溢出時更新(內部會判斷)# 更新縮放因子(動態調整)scaler.update() # 若本次無溢出,增大因子;若溢出,減小因子并跳過本次更新
關鍵細節:
- torch.cuda.amp.autocast:自動將支持 FP16 的操作轉為 FP16(如卷積、線性層),不支持的仍用 FP32(如 softmax);
- 在 torch.cuda.amp.autocast 上下文(混合精度訓練)中,損失函數默認以 FP32 計算以提高數值穩定性:
- 損失值通常較小:模型輸出的損失(如交叉熵、MSE)數值范圍往往較小(例如 1e-3 ~ 10),FP16 的小數精度有限(僅能精確表示 1e-4 以上的數值),若用 FP32 計算可避免精度損失。
- 梯度計算的起點:損失是反向傳播的起點,其精度直接影響梯度的準確性。FP32 損失能提供更穩定的梯度初始值,減少后續梯度下溢 / 爆炸的風險。
- 實際上,autocast 上下文會自動判斷操作是否適合 FP16:
- 對于計算密集型操作(如卷積、線性層),自動轉為 FP16 以提升速度;
- 對于精度敏感操作(如損失計算、softmax、 BatchNorm),默認保留 FP32 或自動回退到 FP32。
- 在 torch.cuda.amp.autocast 上下文(混合精度訓練)中,損失函數默認以 FP32 計算以提高數值穩定性:
- scaler.step(optimizer):內部會檢查梯度是否溢出(若梯度為 inf/NaN 則跳過更新);
- 保存 / 加載 checkpoint 時,需同步保存scaler.state_dict(),否則縮放因子狀態丟失會導致訓練不穩定。
三、避免 Loss 為 NaN 的實踐方法
Loss 為 NaN 通常源于數值不穩定(梯度爆炸 / 下溢、極端值計算),可從以下方面解決:
-
控制梯度范圍
- 梯度裁剪:如上述代碼中clip_grad_norm_,限制梯度 L2 范數(建議 max_norm=1.0~10.0);
- 調整 GradScaler 參數:通過growth_factor(默認 1.0001,增大因子的速度)和backoff_factor(默認 0.5,減小因子的速度)控制縮放因子,避免過度放大導致梯度爆炸。
# 更保守的scaler配置(適合易溢出場景)scaler = torch.cuda.amp.GradScaler(init_scale=2.**10, # 初始縮放因子(默認2^16,減小初始值更保守)growth_factor=1.1,backoff_factor=0.8)
-
檢查數據與標簽
- 確保輸入數據無異常值(如 inf/NaN),可通過torch.isnan(inputs).any()檢查;
- 標簽需在有效范圍內(如分類任務標簽不超過類別數)。
-
調整關鍵計算為 FP32
- 部分操作在 FP16 下易不穩定(如 softmax、交叉熵、BatchNorm),可強制用 FP32 計算:
with torch.cuda.amp.autocast():outputs = model(inputs)# 強制損失計算用FP32loss = criterion(outputs.float(), labels) # 將outputs轉為FP32
-
降低學習率
- 過大的學習率可能導致參數更新幅度過大,引發數值爆炸。建議初始學習率比 FP32 訓練時小(如縮小 10 倍),再逐步調整。
-
禁用FP16的 BatchNorm/LayerNorm/RevIN等
- BatchNorm 在 FP16 下可能因均值 / 方差計算精度不足導致不穩定,可強制用 FP32:
for m in model.modules():if isinstance(m, nn.BatchNorm2d):m.float() # 將BatchNorm參數轉為FP32
四、PyTorch Lightning 實現混合精度訓練
PyTorch Lightning 通過封裝torch.cuda.amp,大幅簡化混合精度訓練流程,只需在Trainer中設置precision參數。
import pytorch_lightning as pl
from torch.utils.data import DataLoader, Datasetclass MyDataset(Dataset):def __len__(self): return 1000def __getitem__(self, idx): return torch.randn(10), torch.randint(0, 2, (1,)).item()class LitModel(pl.LightningModule):def __init__(self):super().__init__()self.model = nn.Linear(10, 2)self.criterion = nn.CrossEntropyLoss()def training_step(self, batch, batch_idx):x, y = batchlogits = self.model(x)loss = self.criterion(logits, y)self.log("train_loss", loss)return lossdef configure_optimizers(self):return Adam(self.parameters(), lr=1e-3)# 訓練器配置(關鍵:設置precision)
trainer = pl.Trainer(max_epochs=10,accelerator="gpu",devices=1,precision=16, # 16: FP16混合精度;"bf16": BF16混合精度;32: 純FP32gradient_clip_val=1.0 # 梯度裁剪(防NaN)
)# 啟動訓練
model = LitModel()
train_loader = DataLoader(MyDataset(), batch_size=32)
trainer.fit(model, train_loader)
高級配置
- 自定義 GradScaler:若需調整 scaler 參數,可重寫configure_gradient_clipping:
def configure_gradient_clipping(self, optimizer, optimizer_idx, gradient_clip_val, gradient_clip_algorithm):scaler = self.trainer.scaler # 獲取Lightning內部的scalerscaler._init_scale = 2.** 10 # 調整初始縮放因子super().configure_gradient_clipping(optimizer, optimizer_idx, gradient_clip_val, gradient_clip_algorithm)
五、模型層面避坑點
在混合精度訓練中,模型層面需要針對性修改的模塊主要是那些對數值精度敏感、容易在 FP16(半精度)下產生不穩定的組件。這些模塊的特性(如動態范圍大、依賴精確統計量計算)使其在低精度下容易出現梯度下溢、數值爆炸或精度損失,需要特殊處理。以下是需要重點關注和修改的模塊及具體策略:
- 歸一化層(BatchNorm/GroupNorm/LayerNorm)
歸一化層依賴均值(mean)和方差(variance)的統計量計算,這些值通常較小(接近 0),在 FP16 下可能因精度不足導致數值不穩定(如方差變為 0,引發除以 0 錯誤)。此外,歸一化層的縮放參數(gamma/beta)若動態范圍大,也容易在 FP16 下溢出。
修改策略:
- 強制使用 FP32 參數和統計量:
- 將歸一化層的權重(weight)、偏置(bias)及運行統計量(running_mean/running_var)保留在 FP32 精度,僅計算過程中的中間結果可使用 FP16。
for m in model.modules():if isinstance(m, (nn.BatchNorm2d, nn.BatchNorm1d, nn.GroupNorm, nn.LayerNorm)):m.float() # 強制參數為FP32# 對于LayerNorm,可進一步固定eps避免過小值if isinstance(m, nn.LayerNorm):m.eps = 1e-5 # 增大eps,避免除以接近0的方差
- 將歸一化層的權重(weight)、偏置(bias)及運行統計量(running_mean/running_var)保留在 FP32 精度,僅計算過程中的中間結果可使用 FP16。
- 激活函數(Softmax/LogSoftmax/Sigmoid 等)
- Softmax/LogSoftmax 數值穩定化:
減去輸入的最大值(不改變結果),避免指數運算溢出:def safe_softmax(x, dim=-1):x = x - x.max(dim=dim, keepdim=True).values # 數值穩定技巧return torch.softmax(x, dim=dim)# 替換模型中的原生Softmax model.classifier.softmax = safe_softmax # 假設模型最后一層有softmax
- 激活函數輸入截斷:
對輸入范圍進行限制,避免極端值進入激活函數:class SafeSigmoid(nn.Module):def forward(self, x):x = torch.clamp(x, min=-10, max=10) # 截斷到[-10,10],避免梯度下溢return torch.sigmoid(x)
- 優先使用 FP32 計算激活:
對敏感激活函數,強制在 FP32 下計算:with torch.cuda.amp.autocast():x = model.conv(x) # FP16計算x = x.float() # 轉為FP32x = torch.softmax(x, dim=-1) # FP32下計算softmax
- 損失函數(CrossEntropy/MSE 等)
- 損失計算強制 FP32:
將模型輸出轉為 FP32 后再計算損失,避免低精度導致的不穩定:
with torch.cuda.amp.autocast():logits = model(inputs) # FP16輸出# 轉為FP32計算損失loss = criterion(logits.float(), labels)
- 損失計算強制 FP32:
- 損失值截斷或縮放:
對回歸任務的 MSE 損失,可先縮放目標值到合理范圍(如[-1,1]),或對損失值進行截斷:def safe_mse_loss(pred, target):pred = torch.clamp(pred, min=-1000, max=1000) # 截斷預測值target = torch.clamp(target, min=-1000, max=1000)return torch.nn.functional.mse_loss(pred, target)
- 優化器
- 優化器參數保留 FP32:
PyTorch 默認會將優化器狀態(如 Adam 的 m 和 v)存儲為與參數相同的精度,需強制用 FP32:
# 初始化優化器時指定參數為FP32 optimizer = Adam([p.float() for p in model.parameters()], lr=1e-3)
- 優化器參數保留 FP32:
- 模塊修改優先級
- 最高優先級:BatchNorm/GroupNorm(統計量敏感)、Softmax/LogSoftmax(易溢出);
- 高優先級:Transformer 注意力機制(分數計算范圍大)、交叉熵損失(log 運算敏感);
- 中等優先級:Sigmoid 等激活函數(梯度易下溢)、優化器狀態(需 FP32 累積);
- 低優先級:卷積層 / 線性層(PyTorch 對其 FP16 支持較好,通常無需修改)。
通過針對性修改這些模塊,可在混合精度訓練中顯著提升數值穩定性,避免因精度問題導致的 Loss 為 NaN 或模型收斂異常。實際應用中,建議結合訓練日志(如監控縮放因子變化、梯度范圍)逐步調整,找到最適合模型的配置。
六、PyTorch Lightning 中手動指定模塊用 FP32 的方法
PyTorch Lightning(PL)雖然自動封裝了混合精度邏輯,但仍支持手動控制特定模塊的精度(如強制某層用 FP32)。核心思路是在模塊的前向傳播中顯式轉換數據類型,或局部禁用 autocast。
- 方法一:在模型層中顯式轉換為 FP32
適用于需要對特定模塊(如 BatchNorm、損失計算)強制 FP32 的場景,直接在模塊的 forward 方法中轉換輸入 / 參數類型:
import pytorch_lightning as pl
import torch
import torch.nn as nnclass MyModel(nn.Module):def __init__(self):super().__init__()self.conv = nn.Conv2d(3, 64, 3) # 常規卷積層(可FP16)self.bn = nn.BatchNorm2d(64) # 對精度敏感的BN層(強制FP32)self.fc = nn.Linear(64, 10) # 輸出層(可FP16)def forward(self, x):# 1. 卷積層:默認FP16(PL的autocast生效)x = self.conv(x)# 2. BN層:強制FP32計算x = x.float() # 輸入轉為FP32x = self.bn(x) # BN層參數已在初始化時設為FP32(見下方)x = x.half() # 轉回FP16,不影響后續層# 3. 全連接層:默認FP16x = self.fc(x)return xclass LitModel(pl.LightningModule):def __init__(self):super().__init__()self.model = MyModel()# 強制BN層參數為FP32(關鍵)for m in self.model.modules():if isinstance(m, nn.BatchNorm2d):m.float() # 權重和統計量用FP32self.criterion = nn.CrossEntropyLoss()def training_step(self, batch, batch_idx):x, y = batchlogits = self.model(x)# 2. 損失計算:強制FP32(類似原生PyTorch)loss = self.criterion(logits.float(), y) # logits轉為FP32self.log("train_loss", loss)return lossdef configure_optimizers(self):return torch.optim.Adam(self.parameters(), lr=1e-3)
- 方法二:局部禁用 autocast 上下文
若需要某段代碼完全禁用混合精度(強制 FP32),可使用 torch.cuda.amp.autocast(enabled=False) 覆蓋 PL 的全局設置:
class LitModel(pl.LightningModule):def training_step(self, batch, batch_idx):x, y = batch# PL會自動開啟autocast,但可局部禁用with torch.cuda.amp.autocast(enabled=False): # 此范圍內強制FP32# 例如:對敏感操作(如注意力分數計算)強制FP32logits = self.model(x.float()) # 輸入轉為FP32loss = self.criterion(logits, y)self.log("train_loss", loss)return loss
- 方法三:通過 PrecisionPlugin 自定義精度策略
對于更復雜的場景(如部分模塊用 FP16、部分用 FP32),可自定義 PrecisionPlugin 控制全局精度邏輯:
from pytorch_lightning.plugins import MixedPrecisionPlugin# 自定義混合精度插件
class CustomMixedPrecisionPlugin(MixedPrecisionPlugin):def __init__(self):super().__init__(precision=16, scaler=torch.cuda.amp.GradScaler())def autocast_context_manager(self):# 全局默認開啟autocast,但可在模型中局部禁用return torch.cuda.amp.autocast(dtype=torch.float16)# 在Trainer中使用自定義插件
trainer = pl.Trainer(accelerator="gpu",precision=16,plugins=[CustomMixedPrecisionPlugin()],max_epochs=10
)
- 總結
在大多數情況下,torch.cuda.amp.autocast 能夠自動判斷操作是否適合 FP16,無需手動干預(如強制類型轉換)。PyTorch 對 autocast 的設計目標就是 “開箱即用”,通過內置的操作映射規則,自動為不同操作選擇最優精度。
autocast 內部維護了一份操作 - 精度映射表,對 PyTorch 原生算子(如卷積、線性層、激活函數等)做了精細化適配:- 優先 FP16 的操作:計算密集型且對精度不敏感的操作,如 nn.Conv2d、nn.Linear、nn.ReLU 等,這些操作在 FP16 下速度提升明顯,且精度損失可接受。
- 自動回退 FP32 的操作:精度敏感或易溢出的操作,如:
- 損失函數(nn.CrossEntropyLoss、nn.MSELoss 等);
- 歸一化層(nn.BatchNorm、nn.LayerNorm 的內部統計量計算);
- 數值不穩定的操作(torch.softmax、torch.log_softmax 等)。
- 例如,當你在 autocast 上下文內調用 loss = criterion(outputs, labels) 時,即使 outputs 是 FP16,PyTorch 也會自動將其轉換為 FP32 進行損失計算,再將損失以 FP32 輸出 —— 整個過程無需手動干預。
盡管 autocast 設計得很智能,但在自定義操作或復雜模型結構中,可能出現自動適配不符合預期的情況,此時需要手動控制精度。常見場景包括:
- 自定義算子或未被 autocast 覆蓋的操作
如果模型中包含 PyTorch 原生算子之外的自定義操作(如自研 CUDA 算子、特殊數學運算),autocast 可能無法識別,導致其默認使用 FP32(影響速度)或錯誤使用 FP16(導致精度問題)。比如在時間序列模型經常使用RevIN模塊,該模塊為了解決時序的分布漂移,如果不手動干預,torch.amp.autocast是不能很好處理的. - 模型中間層出現數值異常(如溢出 / 下溢)
即使使用原生算子,某些特殊場景(如輸入值范圍極端、模型深度過深)可能導致中間層數值異常(如 FP16 下卷積輸出突然變為 inf)。此時需對異常層手動干預。 - 對精度有極致要求的場景
某些任務(如醫療影像、高精度回歸)對數值精度要求極高,即使 autocast 自動適配,也可能需要關鍵模塊強制 FP32 以減少精度損失。
簡言之,autocast 的自動適配是 “最優解”,手動干預僅作為 “異常修復手段”。實際開發中,建議先依賴自動適配,遇到問題再針對性調整。
七、Debug問題定位
在 PyTorch Lightning(PL)混合精度訓練中出現 Loss 為 NaN,通常是數值不穩定累積的結果(而非突然出現)。定位問題需要從數據→模型→訓練機制→混合精度配置逐步排查,結合 PL 的調試工具可高效定位根因。以下是具體的 debug 流程和操作方法:
7.1 復現與簡化:縮小問題范圍
首先通過簡化實驗快速復現問題,排除偶然因素:
- 縮短訓練流程:用fast_dev_run=True讓模型快速跑 1 個 batch 的訓練 + 驗證,觀察是否立即出現 NaN(排除多 epoch 累積效應)。
trainer = pl.Trainer(fast_dev_run=True, # 快速驗證流程precision=16,accelerator="gpu"
)
- 固定隨機種子:確保結果可復現,排除數據隨機波動導致的偶然 NaN:
pl.seed_everything(42, workers=True) # 固定所有隨機種子
- 減少 batch size:若大 batch 下出現 NaN,嘗試batch_size=1,判斷是否與數據分布不均相關。
7.2 模型層面:定位敏感模塊的數值不穩定
混合精度下,模型中對精度敏感的模塊(如歸一化層、激活函數、注意力分數)易成為 NaN 源頭。可通過模塊輸出監控和精度隔離測試定位問題。
- 監控模塊輸出范圍
在模型的關鍵模塊后添加輸出范圍監控,追蹤數值是否異常膨脹:
class MyModel(nn.Module):def __init__(self):super().__init__()self.conv = nn.Conv2d(3, 64, 3)self.bn = nn.BatchNorm2d(64)self.relu = nn.ReLU()def forward(self, x):x = self.conv(x)# 監控卷積層輸出(易數值膨脹)if torch.isnan(x).any():print("Conv層輸出NaN!")self.log("conv_max", x.max().item()) # 記錄到PL日志x = self.bn(x)# 監控BN層輸出(方差為0會導致NaN)self.log("bn_max", x.max().item())x = self.relu(x)self.log("relu_max", x.max().item())return x
重點關注:
- 卷積 / 線性層輸出是否突然增大(如超過1e4);
- BN 層輸出是否出現inf(可能因方差為 0 導致);
- 激活函數(如 ReLU)后是否仍有極端值(說明激活未起到截斷作用)。
- 隔離測試:逐步禁用模塊的 FP16
若懷疑某模塊在 FP16 下不穩定,可強制其用 FP32 計算,觀察是否消除 NaN(即 “精度隔離測試”):
7.4 混合精度機制:檢查 GradScaler 與精度切換
PL 的混合精度依賴GradScaler和autocast,若這兩者配置不當,會直接導致梯度溢出或參數更新異常。
- 監控 GradScaler 的縮放因子
縮放因子(scale)的異常變化是數值不穩定的重要信號:- 若縮放因子突然從1e4暴跌到1e-2,說明梯度頻繁溢出(Scaler 被迫減小因子);
- 若縮放因子持續為初始值(如2^16)且無增長,可能存在梯度下溢。
在 PL 中可通過Trainer的log_every_n_steps監控縮放因子:
應對措施:若縮放因子暴跌,說明當前學習率可能過大,可嘗試降低學習率(如縮小為原來的 1/10)。# 在LightningModule中添加scaler監控 def training_step(self, batch, batch_idx):# ... 前向傳播 ...self.log("scaler_scale", self.trainer.scaler.get_scale(), prog_bar=True)return loss
- 檢查梯度是否溢出
PL 的Trainer可啟用梯度異常檢測,定位梯度溢出的具體參數:
啟用后,若梯度出現inf/NaN,會打印具體的參數名稱(如conv.weight),直接定位到異常模塊。trainer = pl.Trainer(precision=16,detect_anomaly=True, # 啟用梯度異常檢測(會降低速度,僅調試用)accelerator="gpu" )
- 驗證是否是混合精度本身的問題
對比純 FP32 訓練結果,判斷是否由混合精度機制導致:- 若純 FP32 訓練無 NaN,說明問題與混合精度的數值敏感相關;
- 若純 FP32 仍有 NaN,說明問題在模型或數據本身(如學習率過大、數據異常)。
7.5 訓練配置:排查學習率與梯度裁剪
混合精度下,梯度經過縮放后,實際有效學習率可能被放大,導致參數更新幅度過大,最終引發 NaN。
- 降低學習率并監控參數更新幅度
混合精度訓練的初始學習率建議為純 FP32 的 1/2~1/10(因梯度縮放可能等效放大學習率)。可在configure_optimizers中臨時降低學習率:
def configure_optimizers(self):optimizer = Adam(self.parameters(), lr=1e-4) # 從1e-3降至1e-4return optimizer
同時監控參數更新幅度(更新前后的 L2 距離):
def training_step(self, batch, batch_idx):# ... 前向傳播 ...loss.backward() # 手動觸發反向傳播(便于調試)if batch_idx % 10 == 0: # 每10個batch檢查一次for name, param in self.named_parameters():if param.grad is not None:update_norm = (param.grad * self.optimizers().param_groups[0]['lr']).norm()self.log(f"update_{name}", update_norm, prog_bar=True)return loss
若更新幅度超過1e2,說明學習率可能過大。
2. 檢查梯度裁剪是否生效
PL 的gradient_clip_val參數若配置不當,可能導致梯度未被有效裁剪:
trainer = pl.Trainer(precision=16,gradient_clip_val=1.0, # 裁剪梯度L2范數至1.0gradient_clip_algorithm="norm", # 推薦用L2范數裁剪
)
可在training_step中驗證裁剪效果:
def training_step(self, batch, batch_idx):# ... 前向傳播與反向傳播 ...self.manual_backward(loss) # 手動反向傳播(PL默認自動處理,顯式寫出方便調試)# 裁剪前檢查梯度范數grad_norm = torch.nn.utils.clip_grad_norm_(self.parameters(), max_norm=1.0, norm_type=2)self.log("grad_norm_before_clip", grad_norm, prog_bar=True)self.optimizers().step()self.optimizers().zero_grad()
若grad_norm_before_clip持續超過1.0,說明裁剪未生效(可能是 PL 版本 bug,可嘗試手動裁剪)。