- Tác giả trình bày những vấn đề của NumPy kèm nhiều ví dụ minh họa
- Các phép toán mảng đơn giản thì dễ với NumPy, nhưng khi số chiều tăng lên thì độ phức tạp và sự rối rắm cũng tăng vọt
- Thiết kế của NumPy như broadcasting và advanced indexing còn thiếu sự rõ ràng và khả năng trừu tượng hóa
- Việc viết mã buộc phải dựa vào phỏng đoán và thử-sai thay vì chỉ định trục một cách tường minh
- Tác giả đưa ra ý tưởng về một ngôn ngữ mảng được cải tiến và sẽ giới thiệu các phương án thay thế cụ thể trong bài viết tiếp theo
Mở đầu: Yêu ghét lẫn lộn với NumPy
- Tác giả cho biết đã dùng NumPy trong thời gian dài nhưng cũng rất thất vọng với các giới hạn của nó
- NumPy là một thư viện thiết yếu và có ảnh hưởng lớn cho phép toán mảng trong Python
- Các thư viện machine learning hiện đại như PyTorch cũng tồn tại những vấn đề tương tự NumPy
Điều dễ và điều khó ở NumPy
- Những phép toán đơn giản như giải hệ phương trình tuyến tính cơ bản có thể thực hiện bằng cú pháp rõ ràng và thanh lịch
- Nhưng khi số chiều của mảng tăng lên hoặc phép toán trở nên phức tạp hơn, việc xử lý hàng loạt mà không dùng for loop trở thành bắt buộc
- Trong các môi trường không thể dùng vòng lặp trực tiếp (như tính toán trên GPU), cần đến cú pháp vector hóa đặc thù hoặc cách gọi hàm đặc biệt
- Tuy nhiên, cách dùng chính xác của các hàm này lại mơ hồ và khó hiểu rõ chỉ qua tài liệu
- Trên thực tế, với mảng nhiều chiều, gần như không ai có thể tự tin chắc chắn phải dùng
linalg.solve của numpy như thế nào cho đúng
Các vấn đề của NumPy
- NumPy thiếu một lý thuyết nhất quán để áp dụng phép toán lên một phần hay một trục cụ thể của mảng nhiều chiều
- Khi số chiều của mảng là 2 trở xuống thì còn rõ ràng, nhưng từ 3 chiều trở lên thì việc xác định trục nào là đối tượng của phép toán trở nên mơ hồ với từng mảng
- Nó buộc người dùng phải dùng những cách phức tạp như None, broadcasting,
np.tensordot để canh khớp số chiều một cách tường minh
- Những cách này dễ gây sai sót, làm giảm khả năng đọc mã và tăng nguy cơ lỗi
Vòng lặp và sự rõ ràng
- Nếu thực sự cho phép dùng vòng lặp, người ta có thể viết mã ngắn gọn và rõ ràng hơn
- Mã dùng vòng lặp có thể trông kém tinh tế hơn, nhưng lại có lợi thế lớn về mặt rõ ràng
- Ngược lại, khi số chiều mảng thay đổi, người viết phải cân nhắc từng phép transpose hay thứ tự trục, khiến độ phức tạp tăng lên
np.einsum: hàm hiếm hoi thực sự tốt
np.einsum rất mạnh vì cung cấp một ngôn ngữ đặc thù miền linh hoạt cho phép đặt tên các trục
einsum giúp ý định của phép toán trở nên rõ ràng và khả năng khái quát hóa cũng rất tốt, nên có thể hiện thực tường minh các phép toán trục phức tạp
- Nhưng kiểu hỗ trợ phép toán giống einsum chỉ giới hạn ở một số phép toán, ví dụ không thể dùng cho
linalg.solve
Vấn đề của broadcasting
- Broadcasting, thủ thuật cốt lõi của NumPy, là tính năng tự động canh khớp khi số chiều không khớp nhau
- Trong các trường hợp đơn giản thì tiện lợi, nhưng trên thực tế nó khiến việc nắm rõ số chiều trở nên khó hơn và có nhiều tình huống dễ lỗi
- Vì broadcasting là ngầm định, mỗi lần đọc mã người ta lại phải kiểm tra phép toán thực sự hoạt động như thế nào
Sự mơ hồ của indexing
- Advanced indexing của NumPy khiến việc dự đoán shape của mảng rất khó và thiếu rõ ràng
- Tùy theo tổ hợp indexing khác nhau mà shape của mảng kết quả thay đổi, nên nếu chưa từng trực tiếp xử lý thì rất khó đoán trước
- Tài liệu giải thích các quy tắc indexing cũng dài và phức tạp, làm tốn nhiều thời gian để học
- Ngay cả khi chỉ muốn dùng indexing đơn giản, trong một số phép toán cụ thể người dùng vẫn buộc phải dùng advanced indexing
Giới hạn trong thiết kế hàm của NumPy
- Nhiều hàm của NumPy chỉ được tối ưu cho một số shape mảng nhất định
- Với mảng nhiều chiều, phải dùng thêm tham số axes, tên hàm khác, hoặc các quy ước riêng, nhưng giữa các hàm lại thiếu tính nhất quán
- Đây là cấu trúc đi ngược lại nguyên tắc lập trình cơ bản là trừu tượng hóa và tái sử dụng
- Ngay cả khi đã viết được một hàm giải quyết một vấn đề cụ thể, muốn áp dụng lại cho các mảng và trục khác nhau thì thường phải viết lại hẳn mã khác
Ví dụ thực tế: triển khai self-attention
- Khi viết self-attention bằng NumPy, nếu dùng vòng lặp thì rõ ràng hơn, nhưng nếu buộc phải vector hóa thì mã sẽ phức tạp
- Khi cần các phép toán nhiều chiều như multi-head attention, phải kết hợp
einsum với chuyển đổi trục, khiến mã trở nên khó hiểu
Kết luận và phương án thay thế
- Tác giả cho biết NumPy là "lựa chọn duy nhất vừa có nhiều điểm tệ hơn các ngôn ngữ mảng khác, vừa trở nên quan trọng trên thị trường đến mức không thể bỏ qua"
- Để vượt qua nhiều vấn đề của NumPy (broadcasting, sự mơ hồ của indexing, tính thiếu nhất quán của hàm, v.v.), tác giả cho biết đã tạo một nguyên mẫu ngôn ngữ mảng cải tiến
- Các đề xuất cải tiến cụ thể (API ngôn ngữ mảng mới) sẽ được giới thiệu trong một bài viết riêng sau này
4 bình luận
Nghe giống như câu chuyện về lý do Julia ra đời. Dù phải học các thư viện, nhưng vì nó giải quyết được nhiều vấn đề của NumPy nên có vẻ là một lựa chọn thật sự rất hấp dẫn.
numpy nếu không tận dụng tốt vectorization thì hiệu năng sẽ tệ hẳn. Viết có tính đến mấy thứ đó vừa căng thẳng vừa khó.
Mấy thư viện Python hơi cũ có vẻ đều gặp những vấn đề khá giống nhau.
Ý kiến Hacker News
brồi đọc tài liệu thì sẽ khó hiểu, nhưng vì có mô tả vềshapeđược trả về nên cần kiểm tra xem vectorbthực sự có dạng ma trận hay không, đặc biệt là khiK=1transpose, nên phần lớn các vấn đề kiểu này được giải quyết; Xarray yếu hơn NumPy về mặt đại số tuyến tính nhưng có thể dễ dàng quay lại NumPy và chỉ cần viết vài hàm trợ giúp; dùng Xarray giúp tăng năng suất đáng kể khi xử lý dữ liệu từ 3 chiều trở lênda.sel(x=some_x).isel(t=-1).mean(["y", "z"])rất dễ, broadcasting cũng rõ ràng vì tên chiều được tôn trọng, và nó mạnh trong xử lý dữ liệu địa không gian với nhiều CRS khác nhau; kết hợp với Arviz cũng rất tốt nên việc xử lý thêm chiều trong phân tích Bayes trở nên dễ dàng; cũng có thể gộp nhiều mảng vào mộtdatasetđể chia sẻ cùng tọa độ, nên có thể dễ dàng áp dụng cho mọi mảng có trục thời gian nhưds.isel(t=-1)array[:, :, None]là khó chịu, nên rất vui khi thấy có người cùng quan điểmnp.linalg.solvevì cho rằng đó luôn là nhanh nhất là đúng đắn; có nhiều lý do khiến việc tự viết kernel chuyên biệt cho bài toán lại tốt hơn\\numpyso với Matlab, Julia thì đó là: mỗi hàm lại có tham số liên quan đến trục, cách đặt tên và kiểu hỗ trợ vector hóa khác nhau; và nếu muốn áp dụng hàm lên một trục nào đó thì thường phải viết lại mã hoàn toàn; trong khi nền tảng của lập trình là trừu tượng hóa thì NumPy lại làm điều đó khó hơn; ở Matlab, mã vector hóa thường gần như chạy nguyên trạng hoặc cách sửa rất rõ ràng, còn với NumPy thì lúc nào cũng phải lục tài liệu, việc khớp kiểu bằngtranspose/reshapecũng không nhất quán nên rất mơ hồjaxnovmapsqueezecác kiểu; bản thân vấn đề này mơ hồ đến mức khó mà hiểu nó là vấn đề gìreshapenumpylà không rõ phép toán nào sẽ chạy theo kiểu vector hóa, và cũng không thể biểu đạt tường minh bằng cú phápdotnhư Julia; cả về kiểu trả về cũng có rất nhiều bẫy; ví dụ với đối tượngpoly1dP, nếu nhân từ bên phải vớiz0thì kết quả vẫn làpoly1d, nhưng nếu nhân từ bên trái theo dạngz0*Pthì chỉ trả về mảng, nên kiểu bị chuyển đổi một cách âm thầm; ngay cả hệ số đầu của đa thức bậc hai cũng có thể truy cập bằng cảP.coef[0]vàP[2], rất dễ gây nhầm lẫn; chính thức thìpoly1dlà API “cũ” và mã mới được khuyến nghị dùng lớpPolynomial, nhưng trên thực tế cũng không có cảnh báo deprecated nào; những chỗ như vậy, nơi có chuyển kiểu và dữ liệu không nhất quán, nằm rải rác khắp thư viện như mìn gài và biến việc debug thành ác mộngto_numpy(); cuối cùng thời gian đổi qua đổi lại định dạng dữ liệu còn nhiều hơn thời gian giải quyết bài toán; Julia cũng không phải chỉ có ưu điểm, nhưng khả năng liên thông giữa các thư viện như đơn vị đo và độ bất định là rất tốt, còn Python thì lúc nào cũng cần rất nhiều mã boilerplatearray-apiđang cố gắng chuẩn hóa API thao tác mảng trên toàn bộ hệ sinh thái Pythonnumpythay vìsagenumpysanevàgnuplotlib; từ khi có bộ đôi này thì tôi tích cực dùngnumpycho mọi việc; nếu không có chúng thì gần như không thể chịu nổinumpysanerốt cuộc vẫn là vòng lặp Python, không phải vector hóa thực sựeinsumvà dùngoptimize="optimal"để áp dụng thuật toán nhân chuỗi ma trận nhằm tăng hiệu năng; thực tế đúng là nhanh hơn khoảng 2 lần so với cách vector hóa thông thường, nhưng điều đáng ngạc nhiên là bản cài đặt ngây thơ dùng vòng lặp lại còn nhanh hơn; ai tò mò về lý do thì hãy xem mã nguồn; tôi đoán vẫn còn chỗ để cải thiện cache coherency bên trongeinsum