Kiến Trúc Máy Tính¶
Kiến trúc máy tính là cách chúng ta xây dựng những cỗ máy thực thi chỉ thị. File này bao gồm hệ thống số, cổng logic, thiết kế CPU, kiến trúc tập lệnh, pipelining, phân cấp bộ nhớ và bộ nhớ ảo — nền tảng phần cứng mà mọi chương trình, framework và mô hình AI cuối cùng đều chạy trên đó.
- Mọi mạng nơ-ron, mọi vòng lặp huấn luyện, mọi lần suy luận cuối cùng đều trở thành một chuỗi các tín hiệu điện chảy qua các bóng bán dẫn. Hiểu về phần cứng không phải là tùy chọn đối với những người làm ML nghiêm túc: nó giải thích tại sao nhân ma trận nhanh, tại sao bộ nhớ là nút thắt cổ chai, tại sao GPU thống trị huấn luyện AI, và tại sao code thân thiện với cache có thể nhanh hơn 100 lần so với code ngây thơ.
Hệ Thống Số¶
-
Máy tính biểu diễn mọi thứ dưới dạng nhị phân (cơ số 2): các dãy số 0 và 1. Mỗi chữ số là một bit. Một nhóm 8 bit là một byte. Giá trị của số nhị phân \(b_{n-1} b_{n-2} \ldots b_1 b_0\) là \(\sum_{i=0}^{n-1} b_i \cdot 2^i\).
-
Ví dụ, \(1011_2 = 1 \cdot 8 + 0 \cdot 4 + 1 \cdot 2 + 1 \cdot 1 = 11_{10}\).
-
Thập lục phân (Hexadecimal) (cơ số 16) là một ký hiệu nhỏ gọn cho nhị phân. Mỗi chữ số hex đại diện cho 4 bit: \(0\text{-}9\) tương ứng với \(0000\text{-}1001\), và \(A\text{-}F\) tương ứng với \(1010\text{-}1111\). Vậy \(\text{0xFF} = 1111\,1111_2 = 255_{10}\). Địa chỉ bộ nhớ và mã màu thường được viết dưới dạng hex.
-
Bù hai (Two's complement) biểu diễn số nguyên có dấu. Với số \(n\) bit, bit có ý nghĩa nhất có trọng số \(-2^{n-1}\) thay vì \(+2^{n-1}\). Bù hai 8-bit có phạm vi từ \(-128\) đến \(+127\). Để lấy số đối: lật tất cả bit và cộng 1. Cách biểu diễn này giúp phép cộng và phép trừ dùng chung một mạch phần cứng, đó là lý do nó phổ biến.
-
Dấu phẩy động IEEE 754 biểu diễn số thực dưới dạng \((-1)^s \times 1.m \times 2^{e-\text{bias}}\), trong đó \(s\) là bit dấu, \(m\) là phần định trị (phần lẻ), và \(e\) là số mũ có độ lệch.
- **float32** (độ chính xác đơn): 1 dấu + 8 mũ + 23 phần định trị = 32 bit. Phạm vi: $\approx \pm 3.4 \times 10^{38}$, độ chính xác: $\approx 7$ chữ số thập phân.
- **float64** (độ chính xác kép): 1 dấu + 11 mũ + 52 phần định trị = 64 bit. Phạm vi: $\approx \pm 1.8 \times 10^{308}$, độ chính xác: $\approx 15$ chữ số thập phân.
- **float16** (nửa độ chính xác): 1 + 5 + 10 = 16 bit. Phạm vi và độ chính xác hạn chế, nhưng dùng một nửa bộ nhớ và băng thông. Được sử dụng rộng rãi trong huấn luyện ML (mixed precision, chương 6).
- **bfloat16**: 1 + 8 + 7 = 16 bit. Cùng phạm vi mũ với float32 nhưng độ chính xác thấp hơn. Được Google thiết kế đặc biệt cho ML: phạm vi mũ đầy đủ ngăn tràn số trong quá trình huấn luyện, và độ chính xác giảm là chấp nhận được cho cập nhật gradient.
- Số học dấu phẩy động là không chính xác. \(0.1 + 0.2 \neq 0.3\) trong float64 (nó bằng \(0.30000000000000004\)). Điều này là do \(0.1\) không có biểu diễn nhị phân chính xác, giống như \(1/3\) không có biểu diễn thập phân chính xác. Tích lũy các sai số này qua hàng triệu phép toán (như hạ gradient) có thể gây mất ổn định số học, đó là lý do tồn tại các kỹ thuật như scaling mất mát (chương 6) và tổng Kahan.
Cổng Logic¶
-
Mọi tính toán đều quy về cổng logic: các mạch vật lý hiện thực các phép toán Boolean (logic mệnh đề từ file 1).
-
Các cổng cơ bản:
- AND: đầu ra là 1 chỉ khi cả hai đầu vào đều là 1.
- OR: đầu ra là 1 nếu có ít nhất một đầu vào là 1.
- NOT (bộ đảo): lật đầu vào.
- NAND (NOT-AND): cổng vạn năng. Bất kỳ cổng nào khác đều có thể được xây từ chỉ các cổng NAND. Đây là lý do NAND là khối xây dựng cơ bản của mạch số.
- XOR (OR loại trừ): đầu ra là 1 nếu đầu vào khác nhau. Thiết yếu cho phép cộng (bit tổng của phép cộng nhị phân là XOR) và mật mã học.
-
Một bộ bán tổng (half adder) cộng hai bit đơn lẻ dùng XOR (tổng) và AND (nhớ). Một bộ tổng đầy đủ (full adder) cộng hai bit cộng với một bit nhớ đầu vào, xâu chuỗi lại để tạo thành bộ cộng \(n\) bit. Đây là cách CPU thực hiện phép cộng số nguyên: một tầng các cổng logic đơn giản.
-
Một bộ dồn kênh (multiplexer - MUX) chọn một trong nhiều đầu vào dựa trên tín hiệu điều khiển. Với \(n\) bit điều khiển, nó chọn từ \(2^n\) đầu vào. Multiplexer là tương đương phần cứng của một chuỗi if-else và được sử dụng rộng rãi trong đường dữ liệu CPU để định tuyến dữ liệu.
-
Các bộ xử lý hiện đại chứa hàng tỷ bóng bán dẫn, mỗi cái hoạt động như một công tắc nhỏ. Một bóng bán dẫn hoặc bật (dẫn điện, đại diện cho 1) hoặc tắt (không dẫn điện, đại diện cho 0). Cổng được xây từ bóng bán dẫn, bộ cộng từ cổng, ALU từ bộ cộng, và CPU từ ALU. Toàn bộ hệ thống phân cấp của máy tính dựa trên nền tảng này.
Kiến Trúc CPU¶
-
Đơn vị xử lý trung tâm (CPU) thực thi các chỉ thị. Các thành phần cốt lõi của nó:
-
ALU (Đơn vị Logic Số học): thực hiện số học số nguyên (cộng, trừ, nhân) và các phép toán logic (AND, OR, XOR, dịch chuyển). Đây là nơi tính toán thực sự xảy ra, được xây từ các cổng logic đã mô tả ở trên.
-
Thanh ghi (Registers): các vị trí lưu trữ cực nhỏ, cực nhanh bên trong CPU. Một CPU hiện đại có hàng chục thanh ghi đa năng, mỗi thanh giữ một word (64 bit trên CPU 64-bit). Thanh ghi là bộ nhớ nhanh nhất trong hệ thống: truy cập mất ~0.3 nano giây.
-
Bộ đếm chương trình (PC): chứa địa chỉ bộ nhớ của chỉ thị tiếp theo cần thực thi.
-
Khối điều khiển (Control Unit): giải mã chỉ thị và điều phối đường dữ liệu, chỉ dẫn ALU thực hiện phép toán nào và sử dụng thanh ghi nào.
-
-
Chu kỳ chỉ thị (fetch-decode-execute) lặp lại hàng tỷ lần mỗi giây:
- Nạp (Fetch): đọc chỉ thị từ bộ nhớ tại địa chỉ trong PC.
- Giải mã (Decode): xác định chỉ thị làm gì (cộng? nạp từ bộ nhớ? rẽ nhánh?) và toán hạng nào nó sử dụng.
- Thực thi (Execute): thực hiện phép toán (tính toán ALU, truy cập bộ nhớ hoặc rẽ nhánh).
- Tăng PC (trừ khi chỉ thị là lệnh nhảy/rẽ nhánh).
-
Một CPU chạy ở 4 GHz thực hiện 4 tỷ chu kỳ mỗi giây. Mỗi chu kỳ mất 0.25 nano giây. Trong thời gian đó, ánh sáng đi được khoảng 7.5 cm, đó là lý do kích thước chip vật lý quan trọng: tín hiệu không thể vượt qua một chip lớn trong một chu kỳ.
Kiến Trúc Tập Lệnh¶
-
Kiến trúc tập lệnh (ISA) là hợp đồng giữa phần cứng và phần mềm: nó định nghĩa các chỉ thị mà CPU hiểu, tập thanh ghi, mô hình bộ nhớ và định dạng mã hóa.
-
CISC (Máy tính tập lệnh phức tạp): chỉ thị có thể phức tạp, độ dài thay đổi và có thể truy cập bộ nhớ trực tiếp. Một chỉ thị đơn có thể nhân hai giá trị trong bộ nhớ và lưu kết quả. x86 (Intel/AMD) là ISA CISC thống trị, cung cấp năng lượng cho hầu hết máy tính để bàn và máy chủ. Khả năng tương thích ngược của nó (CPU x86 hiện đại vẫn chạy được code từ những năm 1980) vừa là điểm mạnh vừa là gánh nặng.
-
RISC (Máy tính tập lệnh rút gọn): chỉ thị đơn giản, độ dài cố định và chỉ hoạt động trên thanh ghi. Truy cập bộ nhớ đòi hỏi các chỉ thị load/store riêng biệt. Các chỉ thị đơn giản hơn cho phép tốc độ xung nhịp nhanh hơn và pipelining dễ dàng hơn.
- ARM: ISA RISC thống trị cho thiết bị di động và ngày càng cho máy chủ và laptop (chip Apple M-series là ARM). Hiệu suất năng lượng của ARM làm nó lý tưởng cho các thiết bị chạy pin và bị ràng buộc nhiệt.
- RISC-V: một ISA RISC mã nguồn mở. Bất kỳ ai cũng có thể thiết kế chip RISC-V mà không cần phí bản quyền. Đang được áp dụng ngày càng nhiều trong hệ thống nhúng, nghiên cứu và tăng tốc AI.
-
Sự phân biệt CISC vs RISC đã mờ nhạt: các CPU x86 hiện đại nội bộ giải mã các chỉ thị CISC phức tạp thành các vi chỉ thị đơn giản hơn (về cơ bản là RISC nội bộ), nhận được lợi ích của cả hai thế giới.
Pipelining¶
- Nếu không có pipelining, CPU hoàn thành một chỉ thị hoàn toàn trước khi bắt đầu chỉ thị tiếp theo. Điều này lãng phí phần cứng: trong khi ALU thực thi, các khối nạp và giải mã ngồi không.
-
Pipelining chồng lấn việc thực thi chỉ thị, giống như dây chuyền lắp ráp. Trong khi chỉ thị 1 đang thực thi, chỉ thị 2 đang được giải mã, và chỉ thị 3 đang được nạp. Một pipeline 5 giai đoạn (nạp, giải mã, thực thi, truy cập bộ nhớ, ghi kết quả) có thể có 5 chỉ thị đang bay đồng thời.
-
Thông lượng tiến tới một chỉ thị mỗi chu kỳ (mặc dù mỗi chỉ thị mất 5 chu kỳ để hoàn thành). Đây là cùng nguyên lý với pipelining trong ML: song song dữ liệu chồng lấn tính toán và truyền thông (chương 6).
-
Hazard là các tình huống phá vỡ pipelining:
-
Hazard dữ liệu: chỉ thị 2 cần một kết quả mà chỉ thị 1 chưa tạo ra. "Add R1, R2, R3" theo sau bởi "Sub R4, R1, R5" — chỉ thị thứ hai cần R1, mà chỉ thị thứ nhất vẫn đang tính. Chuyển tiếp (Forwarding) (vượt) giải quyết điều này bằng cách định tuyến kết quả trực tiếp từ một giai đoạn pipeline này sang giai đoạn khác mà không cần đợi giai đoạn ghi kết quả.
-
Hazard điều khiển: một chỉ thị rẽ nhánh (if-else) khiến CPU không biết nên nạp chỉ thị nào tiếp theo cho đến khi rẽ nhánh được phân giải. Dự đoán rẽ nhánh (Branch prediction) đoán hướng rẽ nhánh sẽ đi và nạp đầu cơ các chỉ thị dọc theo đường dẫn được dự đoán. Các bộ dự đoán hiện đại chính xác >95%, sử dụng bảng lịch sử và kỹ thuật so khớp mẫu giống mạng nơ-ron. Một dự đoán sai tốn ~15 chu kỳ (pipeline phải được xả và khởi động lại).
-
Hazard cấu trúc: hai chỉ thị cần cùng một tài nguyên phần cứng đồng thời (ví dụ, cả hai cần cổng bộ nhớ). Được giải quyết bằng cách nhân bản tài nguyên hoặc chèn một chu kỳ chờ.
-
Phân Cấp Bộ Nhớ¶
- Sự căng thẳng cơ bản trong bộ nhớ máy tính: bộ nhớ nhanh thì đắt và nhỏ, bộ nhớ rẻ thì chậm và lớn. Phân cấp bộ nhớ thu hẹp khoảng cách này bằng cách khai thác tính địa phương (locality): các chương trình có xu hướng truy cập cùng một dữ liệu lặp đi lặp lại (tính địa phương thời gian) và truy cập dữ liệu lân cận (tính địa phương không gian).
-
Phân cấp, từ nhanh nhất đến chậm nhất:
- Thanh ghi: ~0.3 ns truy cập, tổng ~KB. Bên trong CPU.
- Bộ nhớ đệm L1: ~1 ns, 32-64 KB mỗi lõi. Phân chia thành cache chỉ thị và cache dữ liệu.
- Bộ nhớ đệm L2: ~4 ns, 256 KB-1 MB mỗi lõi.
- Bộ nhớ đệm L3: ~10 ns, 8-64 MB chia sẻ giữa các lõi.
- RAM (DRAM): ~50-100 ns, 8-512 GB. Bộ nhớ chính.
- SSD: ~10-100 μs, 256 GB-8 TB. Lưu trữ liên tục.
- HDD: ~5-10 ms, 1-20 TB. Cơ học, rất chậm cho truy cập ngẫu nhiên.
-
Khoảng cách tốc độ giữa thanh ghi và RAM là ~300 lần. Giữa thanh ghi và đĩa, nó ~30,000,000 lần. Hệ thống phân cấp cache ẩn đi khoảng cách này: nếu dữ liệu CPU cần nằm trong cache L1 (một cache hit), truy cập nhanh. Nếu không (một cache miss), CPU dừng lại trong khi dữ liệu được nạp từ một cấp chậm hơn.
-
Tính kết hợp của cache (Cache associativity) xác định nơi một địa chỉ bộ nhớ có thể được lưu trong cache:
- Ánh xạ trực tiếp (Direct-mapped): mỗi địa chỉ ánh xạ đến đúng một dòng cache. Đơn giản nhưng gây xung đột.
- Kết hợp đầy đủ (Fully associative): bất kỳ địa chỉ nào cũng có thể đi bất kỳ đâu. Linh hoạt nhưng đắt để tìm kiếm.
- Kết hợp tập hợp (Set-associative) (\(k\)-ngả): mỗi địa chỉ ánh xạ đến một tập gồm \(k\) vị trí. Sự thỏa hiệp thực tế được sử dụng trong CPU thực (thường là 4-ngả hoặc 8-ngả).
-
Sự nhất quán cache (Cache coherence) đảm bảo rằng tất cả các lõi CPU thấy một góc nhìn nhất quán về bộ nhớ. Khi lõi 1 ghi vào một địa chỉ mà lõi 2 đã lưu trong cache, giao thức nhất quán (ví dụ, MESI) vô hiệu hóa hoặc cập nhật bản sao của lõi 2. Điều này rất quan trọng cho lập trình đồng thời (file 4) và là một lý do tại sao song song bộ nhớ dùng chung là khó.
-
Đối với người làm ML, phân cấp bộ nhớ giải thích tại sao:
- Các phép toán ma trận nên truy cập bộ nhớ một cách tuần tự (bố trí row-major vs column-major quan trọng).
- Kích thước batch ảnh hưởng đến hiệu suất: các batch lớn hơn làm giảm độ trễ bộ nhớ.
- Độ chính xác hỗn hợp (float16/bfloat16) nhân đôi băng thông bộ nhớ hiệu dụng, thường là nút thắt cổ chai.
Bộ Nhớ Ảo¶
-
Bộ nhớ ảo cho mỗi tiến trình ảo tưởng về việc có không gian bộ nhớ lớn, liền kề của riêng nó, mặc dù RAM vật lý bị giới hạn và được chia sẻ giữa các tiến trình.
-
Không gian địa chỉ được chia thành các trang (pages) có kích thước cố định (thường là 4 KB). Bảng trang (page table) ánh xạ số trang ảo đến số khung vật lý. Khi một chương trình truy cập địa chỉ ảo 0x1234, CPU dịch nó thành địa chỉ vật lý bằng cách tra bảng trang.
-
Bộ đệm dịch địa chỉ (TLB) là một cache cho các mục của bảng trang. Vì bảng trang nằm trong RAM (chậm), TLB lưu các bản dịch được sử dụng gần đây trong phần cứng nhanh. Một TLB miss đòi hỏi phải duyệt bảng trang trong bộ nhớ, tốn hàng trăm chu kỳ.
-
Một lỗi trang (page fault) xảy ra khi một chương trình truy cập một trang không có trong RAM vật lý. Hệ điều hành nạp trang từ đĩa (swap), tốn hàng triệu chu kỳ. Quá nhiều lỗi trang (thrashing) tàn phá hiệu suất. Đây là lý do huấn luyện ML đòi hỏi đủ RAM để chứa mô hình, trạng thái optimizer và một batch dữ liệu hợp lý.
-
Các thuật toán thay thế trang quyết định trang nào sẽ bị đẩy ra khi RAM đầy:
- LRU (Ít được dùng gần đây nhất): đẩy trang đã lâu nhất không được truy cập. Tối ưu trong thực tế cho hầu hết các khối lượng công việc. Được xấp xỉ trong phần cứng bằng thuật toán clock (một danh sách vòng với các bit tham chiếu).
- FIFO: đẩy trang cũ nhất. Đơn giản nhưng có thể đẩy các trang thường dùng.
- Tối ưu (Bélády): đẩy trang sẽ không được dùng trong thời gian dài nhất. Không thể hiện thực (đòi hỏi kiến thức tương lai) nhưng hữu ích như một chuẩn lý thuyết.
-
Bộ nhớ ảo cũng cung cấp sự cô lập: mỗi tiến trình có không gian địa chỉ ảo riêng. Một lỗi trong một tiến trình không thể làm hỏng bộ nhớ của tiến trình khác, vì địa chỉ ảo của chúng ánh xạ đến các khung vật lý khác nhau. Đây là nền tảng của bảo mật và ổn định của hệ điều hành.
I/O, Ngắt và DMA¶
-
CPU cần giao tiếp với thế giới bên ngoài: đĩa, card mạng, bàn phím, GPU. Đây là hệ thống I/O (vào/ra).
-
I/O được lập trình (Programmed I/O) (polling): CPU liên tục kiểm tra thanh ghi trạng thái của thiết bị trong một vòng lặp, chờ dữ liệu sẵn sàng. Đơn giản nhưng lãng phí chu kỳ CPU quay vòng thay vì làm việc có ích.
-
I/O hướng ngắt (Interrupt-driven I/O): thiết bị gửi một ngắt phần cứng khi dữ liệu sẵn sàng. CPU tiếp tục thực thi bình thường cho đến khi ngắt đến, sau đó chạy một bộ xử lý ngắt (một hàm kernel) để xử lý dữ liệu. Điều này hiệu quả hơn nhiều so với polling vì CPU không nhàn rỗi trong khi chờ.
-
Cơ chế ngắt:
- Một thiết bị báo hiệu ngắt qua một đường dây phần cứng.
- CPU hoàn thành chỉ thị hiện tại, lưu trạng thái hiện tại (thanh ghi, bộ đếm chương trình) lên ngăn xếp.
- CPU tra cứu địa chỉ bộ xử lý ngắt trong bảng vector ngắt (một bảng các con trỏ hàm, một cho mỗi loại ngắt).
- Bộ xử lý chạy ở chế độ kernel, xử lý I/O và trả về.
- CPU khôi phục trạng thái đã lưu và tiếp tục chương trình bị ngắt.
-
Đây là cùng mẫu lưu/khôi phục như chuyển đổi ngữ cảnh (file 3), nhưng được kích hoạt bởi phần cứng thay vì bộ định thời.
-
DMA (Truy cập bộ nhớ trực tiếp): đối với các truyền dữ liệu lớn (đọc đĩa, gói mạng, sao chép bộ nhớ GPU), việc để CPU sao chép dữ liệu từng byte một rất lãng phí. Một bộ điều khiển DMA truyền dữ liệu trực tiếp giữa thiết bị và RAM mà không liên quan đến CPU. CPU thiết lập truyền (nguồn, đích, kích thước), bộ điều khiển DMA xử lý nó, và CPU nhận một ngắt khi hoàn tất.
-
DMA rất quan trọng cho ML: khi bạn gọi
model.to('cuda'), dữ liệu được truyền từ RAM hệ thống đến bộ nhớ GPU qua DMA trên bus PCIe. Trong quá trình huấn luyện, đồng bộ hóa gradient giữa các GPU sử dụng RDMA (DMA từ xa) dựa trên DMA cho truyền tải băng thông cao, độ trễ thấp (chương 6). -
Bus kết nối CPU với bộ nhớ và các thiết bị I/O. Các hệ thống hiện đại sử dụng PCIe (Peripheral Component Interconnect Express) cho các thiết bị tốc độ cao (GPU, SSD NVMe, card mạng). PCIe 4.0 cung cấp ~32 GB/s mỗi khe x16; PCIe 5.0 nhân đôi con số này. Băng thông bus thường là nút thắt cổ chai cho huấn luyện GPU: GPU có thể tính toán nhanh hơn so với tốc độ dữ liệu có thể được cấp cho nó.
-
MMIO (Memory-Mapped I/O): các thanh ghi thiết bị được ánh xạ đến địa chỉ bộ nhớ. CPU đọc và ghi vào các địa chỉ này bằng các chỉ thị load/store thông thường, và phần cứng định tuyến truy cập đến thiết bị thay vì RAM. Điều này hợp nhất truy cập bộ nhớ và I/O thành một cơ chế duy nhất, đơn giản hóa cả phần cứng và phần mềm.
Bài Tập Lập Trình (dùng CoLab hoặc notebook)¶
-
Khám phá biểu diễn dấu phẩy động IEEE 754. Chuyển đổi một số float thành biểu diễn nhị phân của nó và quan sát các trường dấu, mũ và phần định trị.
import struct def float_to_bits(f): """Show the IEEE 754 binary representation of a float32.""" packed = struct.pack('>f', f) bits = ''.join(f'{byte:08b}' for byte in packed) sign = bits[0] exponent = bits[1:9] mantissa = bits[9:] return sign, exponent, mantissa for val in [1.0, -1.0, 0.1, 0.5, 3.14, float('inf'), float('nan')]: s, e, m = float_to_bits(val) print(f"{val:>10} sign={s} exp={e} ({int(e, 2) - 127:>4d}) mantissa={m[:10]}...") -
Mô phỏng một cache ánh xạ trực tiếp. Theo dõi số hit và miss cho một dãy truy cập bộ nhớ.
def simulate_cache(accesses, cache_size=8, block_size=1): """Simulate a direct-mapped cache.""" cache = [None] * cache_size hits, misses = 0, 0 for addr in accesses: cache_line = addr % cache_size if cache[cache_line] == addr: hits += 1 status = "HIT " else: misses += 1 cache[cache_line] = addr status = "MISS" print(f" Access {addr:3d} → line {cache_line}: {status}") print(f"\nHits: {hits}, Misses: {misses}, Hit rate: {hits/(hits+misses):.1%}") # Sequential access (good locality) print("Sequential access:") simulate_cache([0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3]) # Strided access (conflict misses) print("\nStrided access (stride = cache size):") simulate_cache([0, 8, 0, 8, 0, 8]) -
Chứng minh tại sao số học dấu phẩy động không có tính kết hợp. Chỉ ra các trường hợp \((a + b) + c \neq a + (b + c)\).
import jax.numpy as jnp a = jnp.float32(1e8) b = jnp.float32(1.0) c = jnp.float32(-1e8) left = (a + b) + c # (1e8 + 1) + (-1e8) right = a + (b + c) # 1e8 + (1 + (-1e8)) print(f"(a + b) + c = {left}") # should be 1.0 print(f"a + (b + c) = {right}") # might lose the 1.0 print(f"Equal: {left == right}") print(f"\nThe 1.0 is lost when added to 1e8 because float32 has only ~7 digits of precision")