GPU có sẵn mà không dùng: bốn lỗi âm thầm khi huấn luyện trên máy người dùng

· 21 min read · 4090 words · Read in English

Authors

Suy luận (inference) đã chuyển về chạy ngay trên thiết bị từ nhiều năm trước. Giờ đến lượt huấn luyện: những dữ liệu đáng để đưa vào mô hình nhất thường cũng là thứ người dùng ngại tải lên mạng nhất, trong khi phần cứng cần thiết đã có sẵn trên bàn làm việc.

AnyLearning được xây dựng theo hướng đó: một ứng dụng desktop huấn luyện mô hình thị giác ngay trên máy người dùng. Không cloud, không telemetry, không tải dữ liệu đi đâu cả. Đổi lại, tôi mất một lợi thế mà hầu hết hệ thống MLOps mặc nhiên có: toàn quyền kiểm soát hạ tầng. Vòng lặp huấn luyện phải chạy trên những cấu hình máy tôi chưa từng thấy và không thể biết trước. Khoảng cách giữa “chạy được trên mọi máy” và “tận dụng đúng phần cứng của từng máy” chính là nơi nhiều lỗi bắt đầu xuất hiện.

Suốt hai năm, mọi máy Mac chạy ứng dụng của tôi đều có một GPU hoàn toàn dùng được nằm ngay trên cùng con chip, nhưng quá trình huấn luyện vẫn ì ạch trên CPU. Tôi biết chuyện đó. Hỗ trợ Metal đã nằm trong backlog từ đầu, nhưng quý nào cũng bị những việc cấp bách hơn đẩy xuống. Lý do rất đơn giản: nhìn bên ngoài, chẳng có gì hỏng cả. Huấn luyện vẫn hoàn tất, mô hình vẫn cho kết quả tốt. Nó chỉ chậm hơn mức cần thiết ba đến bốn lần, mà hiếm ai mở issue chỉ vì phần mềm “hơi chậm”.

Nợ kỹ thuật thường là như vậy: không bị quên, chỉ nằm mãi ở cuối hàng. Đến khi chi phí thực hiện đủ thấp, tôi mới kéo việc này lên làm. Tôi làm việc cùng Claude Code trong suốt quá trình; nó đảm nhận phần lớn những việc lặp lại vốn có thể kéo dài dự án thêm nhiều tuần: tích hợp logic chọn thiết bị vào cả năm module huấn luyện, dựng bộ benchmark và theo dõi những lượt chạy dài, nhàm chán.

Từ đầu đến cuối chỉ mất hai ngày. Điều khiến tôi băn khoăn là một việc bị hoãn suốt hai năm hóa ra chỉ cần hai ngày khi phần việc thủ công đã được tự động hóa. Phần lớn thời gian ấy dành cho đo đạc chứ không phải viết code, và đó mới là tỷ lệ hợp lý. Cả bốn lỗi dưới đây đều được phát hiện nhờ số đo; không lỗi nào ném exception. Đây cũng là ý chính của bài viết: lỗi hiệu năng đa nền tảng thường xảy ra trong im lặng, và nhìn bên ngoài chẳng khác gì một lượt chạy thành công.

Nói trước để tránh hiểu nhầm: mọi con số dưới đây được đo trên đúng bốn máy tôi đang có, gồm một MacBook Air M1, một laptop RTX 3080, một desktop RTX 2070 và một workstation RTX 3090. Mỗi cấu hình chỉ có một máy. Đây không phải bộ benchmark đại diện cho thị trường, cũng không phải tập hợp khuyến nghị áp dụng cho mọi trường hợp. Các tỷ lệ chỉ đúng với máy của tôi; điều có thể lặp lại trên máy của bạn là dạng vấn đề, không phải con số cụ thể.

Khi “GPU” thực chất chỉ có nghĩa là CUDA

Toàn bộ logic chọn thiết bị nằm trong một hàm và chỉ kiểm tra đúng một điều:

def get_device(prefer_gpu: bool = True) -> torch.device:
    if prefer_gpu and torch.cuda.is_available():
        return torch.device("cuda")
    return torch.device("cpu")

Logic này đúng với NVIDIA. Nhưng trên M1, nó âm thầm trả về CPU, trong khi giao diện vẫn cho người dùng chọn “GPU”. Biết Metal chưa được hỗ trợ là một chuyện; biết UI đang hứa điều phần mềm không làm được lại là chuyện khác. Tôi chỉ mới phát hiện ra vế sau. Nếu sản phẩm của bạn cũng có mục chọn thiết bị, hãy kiểm tra xem trên từng nền tảng nó thực sự chọn thiết bị nào. UI của tôi đã hứa dùng GPU suốt hai năm mà không test nào phát hiện, vì tất cả chỉ kiểm tra xem quá trình huấn luyện có chạy xong hay không.

Sau khi bật Metal, tôi đo được mức tăng tốc so với CPU như sau. Cột đầu chỉ tính thời gian mỗi epoch; cột sau đo toàn bộ quá trình từ đầu đến cuối:

Loại mô hìnhMỗi epochToàn bộ quá trình
Phân loại ảnh (3 epoch)3.7x0.97x
Phân đoạn ảnh4.2x2.0x
Phát hiện đối tượng1.7x1.26x

Nhưng người dùng không chờ riêng từng epoch; họ chờ cả quá trình hoàn tất. Với bài toán nhỏ nhất, Metal không những mất hết lợi thế mà còn chậm hơn: 98 giây so với 95 giây trên CPU. Gần như toàn bộ chênh lệch nằm ở giai đoạn từ “Training complete” đến “Model saved”: bản chạy Metal mất 60–61 giây, còn bản chạy CPU chỉ mất 20–21 giây.

Tôi không muốn kết luận quá mức từ số liệu này. Mỗi chế độ chỉ được chạy hai lần trên một máy; Metal luôn chạy trước và không có phép đo đối chứng khi máy ở trạng thái rảnh. Chừng đó chưa đủ để loại trừ khả năng một tiến trình khác đã chiếm tài nguyên. Điều đáng rút ra là cách đo: hãy đo toàn bộ khoảng thời gian người dùng phải chờ, không chỉ đoạn code bạn vừa tối ưu.

Bài học về thiết kế còn quan trọng hơn bản thân lỗi này: lựa chọn của người dùng và khả năng của phần cứng là hai khái niệm khác nhau, không nên dùng chung một biến. Người dùng chọn Tự động, GPU hoặc CPU; còn “GPU” được ánh xạ thành CUDA hay Metal phải được quyết định lúc chạy trên chính máy đó. Một project được huấn luyện trên máy trạm dùng CUDA vẫn phải mở được trên Mac và tự chọn GPU phù hợp với máy Mac.

Hộp thoại huấn luyện với Hardware được đặt thành Automatic

Hộp thoại chỉ có ba lựa chọn: Automatic, GPU và CPU — không hiển thị “CUDA” hay “Metal”. Backend cụ thể do phần mềm xác định theo máy, người dùng không cần quan tâm.

Tiến trình chết mà không để lại traceback

Bật Metal không có nghĩa là bài toán phát hiện đối tượng lập tức chạy được. Ngay ở iteration đầu tiên, tiến trình bị SIGABRT: không exception, không traceback, cũng không có dòng log nào trỏ đến code của tôi.

Trước hết, phải lấy được ít nhất một dòng thông tin. abort() dừng tiến trình từ tầng C++, vì vậy Python không có cơ hội xử lý và log hoàn toàn trống. faulthandler cài signal handler ở mức hệ điều hành nên vẫn kịp in ra:

PYTHONFAULTHANDLER=1 python train.py
Current thread 0x0000000170a6f000 (most recent call first):
  <no Python frame>

Hãy đọc dòng này theo đúng nghĩa đen: luồng gặp lỗi không chạy mã Python. Forward pass của tôi chạy bằng Python trên luồng chính, nên có thể loại nó khỏi phạm vi điều tra. PyTorch thực hiện backward trên các luồng worker nằm hoàn toàn trong C++, vì thế chúng không có Python frame. Chỉ một dòng log đã giúp thu hẹp một nửa phạm vi cần kiểm tra.

Tiếp theo, dùng autograd để xác định phép toán gây lỗi. Có thể duyệt ngược đồ thị backward từ loss và gắn hook vào từng node. Cho mỗi node in tên trước khi chạy, rồi để cú crash tự đánh dấu vị trí cuối cùng:

seen = set()

def announce(node):
    if node is None or node in seen:
        return
    seen.add(node)
    # Bắt buộc phải có flush=True: abort() không flush stdout, trong khi
    # dòng cuối cùng lại chính là dòng cần tìm.
    node.register_prehook(
        lambda grads, n=node: print("entering", type(n).__name__, flush=True)
    )
    for nxt, _ in node.next_functions:
        announce(nxt)

announce(loss.grad_fn)
loss.backward()

Tên cuối cùng được in ra là LinearBackward0. Nó nằm trong head Integral của NanoDet, tại lệnh F.linear(x, self.project.type_as(x)). Ở đây, self.project chỉ là vector một chiều chứa chỉ số các bin [0, 1, ... 7]. Các backend khác đều xử lý phép nhân này bình thường. Riêng trình biên dịch đồ thị của Apple lại dựng sai phép nhân, rồi báo chính graph vừa tạo là không hợp lệ:

error: 'mps.matmul' op contracting dimensions differ 4 & 8
note: %2 = "mps.matmul"(%arg1, %arg0)
      : (tensor<1x66x4xf32>, tensor<8xf32>) -> tensor<1x66xf32>
MPSGraphExecutable.mm:1232: failed assertion `original module failed verification'

tensor<8xf32> đúng là project. Nhưng tensor<1x66x4xf32> không phải tensor đầu vào; đó là shape của đầu ra nhưng lại bị dùng làm toán hạng. Assertion thất bại dẫn đến abort(), nên phía Python không còn lỗi nào để bắt.

Thay phép toán đó bằng torch.matmul là giải quyết được vấn đề. Kết quả khớp từng bit với F.linear trên cả CPU lẫn CUDA, còn file ONNX xuất ra giống nhau từng byte. Chừng đó mới đủ để tôi tự tin vá một mô hình của thư viện bên thứ ba. Khi một cú crash không cung cấp bất kỳ manh mối nào, việc đầu tiên không phải là sửa lỗi, mà là tìm cách lấy ra dù chỉ một dòng thông tin.

Không phải lúc nào GPU cũng là lựa chọn đúng

Hỗ trợ nhiều loại phần cứng không có nghĩa là cố đẩy mọi tác vụ lên GPU. Quan trọng hơn là biết trường hợp nào GPU kém hiệu quả và phản ánh đúng điều đó trong sản phẩm. Hai trong năm module huấn luyện của tôi không bao giờ chạy trên Metal. Đây là chủ ý thiết kế, không phải tính năng còn thiếu: hiển thị một lựa chọn nhưng âm thầm không thực hiện đúng lời hứa còn tệ hơn việc không cung cấp lựa chọn đó.

Với phân đoạn thực thể (instance segmentation), mô hình sẽ học từ gradient sai. Kernel roi_align trên Metal của torchvision cộng dồn gradient quá mức trong backward pass: cao gấp 2,96 lần với 8 vùng quan tâm và 191 lần với 512 vùng, so với bản CPU. Forward pass vẫn hoàn toàn chính xác, nên không có dấu hiệu bất thường cho đến khi mô hình không học được. Một bản sửa lỗi đã được merge từ tháng 7/2026, nhưng chưa có trong bất kỳ bản phát hành torchvision nào, kể cả pre-release. Upstream đã merge không có nghĩa là bản thư viện bạn đang dùng đã được sửa.

Với mô hình phân loại cử chỉ tay, GPU lại chậm hơn CPU. Đây chỉ là một MLP nhỏ, nhận 63 giá trị cho mỗi mẫu: mỗi epoch mất 0,173 giây trên CPU nhưng tới 1,545 giây trên Metal. Ban đầu tôi nghĩ đây là đặc thù của Apple, nhưng không phải. Trên workstation, cùng mô hình đó chạy bằng RTX 3090 vẫn chậm hơn Core i9 thế hệ 11 của chính máy ấy từ 1,4 đến 3,1 lần, chưa lần nào nhanh hơn. Trong khi đó, các loại mô hình khác nhanh hơn từ 10 đến 100 lần trên cùng GPU. Khi khối lượng tính toán quá nhỏ, overhead của GPU lớn hơn phần thời gian tiết kiệm được. Ngưỡng này phụ thuộc vào mô hình, không chỉ phụ thuộc vào hãng sản xuất chip.

Trên Metal, 16-bit còn chậm hơn 32-bit — chỉ đạt 0,94x với phân loại ảnh và 0,91x với phát hiện đối tượng; bf16 còn chậm hơn nữa. Cả hai dtype đều cho kết quả đúng và GradScaler cũng hoạt động đúng. Vấn đề là autocast phải chèn phép chuyển kiểu ở mỗi phép toán; trên GPU không có tensor core, chi phí chuyển kiểu còn lớn hơn lượng tính toán tiết kiệm được. M1 cũng không hỗ trợ bf16 nguyên bản, vì vậy một phép nhân ma trận bf16 chỉ đạt 0,62x so với fp32. Nếu bạn dùng M3 trở lên thì không nên áp dụng kết luận này: Apple đã bổ sung bfloat16 cho GPU từ thế hệ đó, còn tôi chưa có máy phù hợp để kiểm chứng.

Hai API không hề báo lỗi

Việc có dùng mixed precision hay không được quyết định theo từng máy, tại một điểm duy nhất trong code:

Thiết bịChế độ
Ampere trở lên (compute 8.0+)bfloat16, không cần gradient scaler
Card CUDA đời cũ hơnfloat16 kèm scaler
CPU và Apple Metalfloat32

Trên RTX 3080, thời gian chạy kernel cải thiện từ 1,42x đến 1,91x. Nhưng khi đo end-to-end trên chính các lượt chạy đó, mức tăng chỉ còn 1,00x đến 1,17x. Với bộ dữ liệu của tôi, dataloader mới là nút thắt, nên giảm một nửa khối lượng tính toán gần như không thay đổi tổng thời gian. Cả hai số đo đều đúng; chúng chỉ trả lời hai câu hỏi khác nhau. Nếu dùng số liệu của kernel để quảng bá hiệu năng toàn hệ thống, sớm muộn cũng sẽ có người đo end-to-end rồi mở bug report.

Nhưng rồi hai API dưới đây vẫn để lỗi lọt ra bản phát hành mà tôi không hề hay biết.

is_bf16_supported() trả về True ngay cả khi phần cứng không hỗ trợ bf16. Trên RTX 2070, hàm này trả về True dù kiến trúc Turing không có bfloat16:

def is_bf16_supported(including_emulation: bool = True):
    if torch.cuda.get_device_properties(device).major >= 8:
        return True
    if not including_emulation:
        return False
    return _check_bf16_tensor_supported(device)   # Có cấp phát được tensor không?

Với tham số mặc định, hàm chỉ thử cấp phát một tensor bfloat16 rồi xem thao tác đó có thành công hay không. RTX 2070 cấp phát được tensor, nhưng mọi phép tính sau đó phải chạy bằng giả lập và chỉ đạt 0,23x hiệu năng của fp32 — tức chậm hơn khoảng 4,3 lần, ngay trên dòng card cũ vốn càng cần được tối ưu. Log không hề cảnh báo. Thay vì tin vào kết quả mặc định, hãy kiểm tra get_device_properties(...).major >= 8; chính torch cũng dùng điều kiện này trước khi xét đến chế độ giả lập.

Điều đáng nói là test dành cho tình huống này vẫn pass. Trong test, tôi mock is_bf16_supported để trả về False, rồi xác nhận code chọn nhánh dành cho card cũ. Nói cách khác, tôi đã mock mất chính hành vi thực tế đang sai. Nếu mock API đang cần kiểm chứng, test chỉ xác nhận giả định của mình chứ không xác nhận hành vi ngoài đời.

Gradient scaler âm thầm bỏ qua optimizer.step(). Vẫn trên RTX 2070, loss của Mask R-CNN còn hữu hạn nhưng gradient đã scale bị tràn số. Khi phát hiện overflow, scaler.step() bỏ qua bước cập nhật optimizer. Bốn trong bảy lần cập nhật đầu tiên bị loại bỏ. Quá trình huấn luyện vẫn hoàn tất với mAP@0.5 là 0,13, so với 0,31 của float32, nhưng log không có bất kỳ cảnh báo nào vì code chỉ theo dõi loss. Tín hiệu duy nhất nằm ở scale factor: nó giảm một nửa đúng tại các vòng lặp bị bỏ qua.

scale_before = scaler.get_scale()
scaler.step(optimizer)
scaler.update()
if scaler.get_scale() < scale_before:
    skipped_steps += 1

Cách sửa nằm ở giá trị scale ban đầu. Torch mặc định bắt đầu từ 65536, sau đó giảm dần mỗi khi overflow cho đến khi tìm được mức phù hợp. Với một lượt chạy vài nghìn bước, chi phí khởi động này không đáng kể; với tổng cộng 60 bước, nó lại chiếm tỷ trọng rất lớn. Khi bắt đầu từ 1024, không bước nào bị bỏ qua và mAP vẫn đạt 0,31. Một lượt fine-tune ngắn không đơn giản là phiên bản thu nhỏ của lượt chạy dài; đó là trường hợp giai đoạn warm-up có thể chiếm gần như toàn bộ quá trình.

Ba lần số đo đánh lừa tôi

Chênh lệch chỉ đến từ một ảnh. Một lượt chạy ban đầu cho kết quả 0,8108, so với 0,8378 của fp32. Nhìn qua, mixed precision dường như làm mất gần ba điểm phần trăm độ chính xác. Nhưng tập validation chỉ có 37 ảnh: 0,8108 tương ứng 30/37, còn 0,8378 là 31/37. Toàn bộ chênh lệch chỉ nằm ở một ảnh. Hai lần chạy tiếp theo khớp đúng kết quả của fp32, nghe có vẻ thuyết phục nhưng thực chất vẫn chỉ là hai mẫu. Khi chạy bốn lần cho mỗi chế độ, độ dao động trong cùng một chế độ lớn gấp bốn lần chênh lệch giữa hai chế độ.

Phép đối chứng đáng lẽ không thể thay đổi. Một GPU đang phải chạy thêm job khác của chính tôi, nhưng các số đo vẫn trông hoàn toàn hợp lý. Trình huấn luyện cử chỉ tay đã giúp phát hiện nhiễu: nó không có bất kỳ đoạn code mixed precision nào — không autocast, không scaler, loss ở hai chế độ giống nhau từng bit. Dù vậy, sau sáu lượt chạy, thời gian giữa hai chế độ vẫn lệch 12%. Chạy kèm một tác vụ không thể bị tác động bởi biến đang đo là cách đơn giản và rẻ nhất để phát hiện yếu tố gây nhiễu.

Công cụ đo tự nó đã sai. Để đo từng mô hình trên từng thiết bị, tôi chạy mỗi cấu hình trong một tiến trình con được tạo bằng fork. Nhưng sau khi tiến trình cha đã chạy backward, tiến trình con kiểu này không thể chạy backward lần nữa; torch lúc thì ném lỗi, lúc lại deadlock. Bộ benchmark đầu tiên vì thế kết luận mọi mô hình đều hỏng trên mọi thiết bị, trong khi lỗi nằm hoàn toàn ở chính công cụ đo. Với trường hợp này, hãy dùng spawn.

Những thay đổi được phát hành sau đó

Logic chọn thiết bị mới được phát hành trong phiên bản 0.26.1. Ở phiên bản tiếp theo, cách tiếp cận tương tự được áp dụng cho một bài toán mới.

Phiên bản 0.26.2 đưa phát hiện điểm mốc (keypoint detection) thành một loại project hoàn chỉnh: gán nhãn các điểm theo nhóm, đánh dấu điểm có nhìn thấy được hay không, import/export COCO, LabelMe và AnyLabeling, huấn luyện bằng RF-DETR, xuất ONNX, rồi trả về tên từng điểm khi inference. Ví dụ dưới đây nhận diện các mốc đốt sống trên phim X-quang cột sống chụp nghiêng, với mô hình được huấn luyện từ 80 ảnh. Dữ liệu y tế là ví dụ điển hình của loại dữ liệu không thể tải lên API của bên khác — cũng là lý do việc huấn luyện cần diễn ra ngay trên máy đang lưu dữ liệu.

Kết quả phát hiện điểm mốc trên phim X-quang cột sống nghiêng, trả về dưới dạng JSON kèm tên từng điểm

Mỗi điểm mốc đốt sống có tên, độ tin cậy và trạng thái hiển thị. Mô hình được huấn luyện từ 80 ảnh ngay trên máy đang lưu các phim X-quang này.

Huấn luyện keypoint cũng đưa vấn đề phần cứng quay trở lại. Đồ thị activation của nó lớn hơn đáng kể so với object detection; batch size 8 luôn dùng hết bộ nhớ dùng chung trên máy M1 16 GB. Vì vậy, phiên bản 0.26.3 giới hạn batch size ở mức 2 trên GPU của Apple. Giới hạn này được áp dụng ở server, không phải trong hộp thoại, vì hệ thống vẫn phải xử lý các job đã lưu và client phiên bản cũ. Ràng buộc phần cứng phải được kiểm tra tại nơi thực thi, không thể chỉ dựa vào UI. CUDA và CPU vẫn giữ batch size người dùng đã chọn, còn log ghi rõ thay đổi vừa được áp dụng:

Reducing keypoint batch size from 8 to 2 for Apple Metal memory safety.

Hướng xử lý lâu dài

Trong một vòng lặp huấn luyện, có ít nhất bốn yếu tố chỉ có thể biết trên máy đang chạy: loại GPU hiện có, dtype nào được phần cứng hỗ trợ nguyên bản thay vì giả lập, nút thắt hiệu năng thực sự nằm ở đâu, và một mẫu validation ảnh hưởng bao nhiêu đến metric. Trước đây tôi dùng cùng một file cấu hình để trả lời cả bốn câu hỏi cho mọi người dùng — và với mỗi câu đều có ít nhất một cấu hình máy khiến câu trả lời đó sai. Đây không chỉ là một lỗi đã sửa xong, mà là cả một nhóm lỗi sẽ ngày càng phổ biến khi hệ sinh thái phần cứng tăng tốc tiếp tục đa dạng.

Biện pháp đơn giản nhất là ghi vào log quyết định mà code vừa đưa ra, kèm theo lý do:

Mixed precision: bfloat16 (this GPU supports bfloat16, which needs no loss scaling).
Training device: GPU (Apple Metal, Apple M1)

Chỉ hai dòng như vậy cũng đủ để phát hiện lỗi ban đầu sớm hơn vài năm. Sẽ có người nhận ra dòng “training on cpu” trên chính chiếc máy họ mua để tận dụng GPU.

Giải pháp lâu dài là ngừng suy đoán hiệu năng dựa trên thế hệ phần cứng. Tôi đang tích hợp một benchmark ngắn vào sản phẩm để chạy trực tiếp trên máy người dùng, đo xem thiết bị và dtype nào nhanh nhất trên chính máy đó, lưu kết quả, rồi chạy lại khi phần cứng thay đổi. Viết cứng logic chọn thiết bị đồng nghĩa với việc tin rằng từ bàn làm việc của mình, bạn có thể liệt kê và dự đoán đúng mọi loại chip trên thị trường. Với AI chạy trên thiết bị người dùng, giả định đó không còn khả thi. Phần mềm phải tự đo và thích nghi với phần cứng đang chạy nó.

What matters

  1. 1Thiết bị và dtype phù hợp chỉ có thể xác định lúc chạy trên từng máy. Một file cấu hình dùng chung không thể biết trước; nếu UI cho chọn “GPU”, backend phải ánh xạ lựa chọn đó theo phần cứng thực tế.
  2. 2SIGABRT kèm “<no Python frame>” cho thấy lỗi xảy ra trong luồng autograd: hãy kiểm tra backward thay vì forward. Gắn prehook vào các node của graph sẽ xác định chính xác phép toán gây lỗi.
  3. 3Upstream đã merge bản sửa lỗi không có nghĩa là thư viện bạn đang dùng đã chứa bản sửa đó. Hãy chạy kernel và đối chiếu với CPU reference, vì gradient sai thường không biểu hiện cho đến khi mô hình không học được.
  4. 4Đừng đánh đồng mức tăng tốc của kernel với mức tăng tốc end-to-end. Trên dữ liệu của tôi, dataloader là nút thắt: nhanh hơn 1,42–1,91x ở phần tính toán nhưng chỉ còn 1,00–1,17x trên tổng thời gian.
  5. 5torch.cuda.is_bf16_supported() trả về True trên Turing, khiến bf16 chỉ đạt 0,23x so với fp32. Gradient scaler có thể âm thầm bỏ qua optimizer step. Cả hai trường hợp đều không ném lỗi.
  6. 6Hãy kiểm tra giới hạn phần cứng ở server, không chỉ ở UI. Job đã lưu và client cũ vẫn có thể gửi cấu hình vượt quá khả năng của GPU.