Bit, byte và 1 + 1: từ cổng NAND tới xung nhịp CPU

Điều gì thực sự xảy ra trong con chip khi máy tính 1 + 1? Từ bit, số âm và số thực, tới cổng NAND, bộ cộng tự lắp bằng Python, xung nhịp CPU đo bằng một chuỗi lệnh cộng, và lệnh máy add nằm trong 4 byte.

Seri này tháo máy tính ra từng lớp, bắt đầu từ lớp thấp nhất: con số. Mục tiêu của bài đầu nghe có vẻ đơn giản: theo dõi phép tính 1 + 1 từ lúc nó là một dòng code tới lúc nó là dòng điện chạy qua vài trăm transistor, và hiểu vì sao một con chip làm được việc đó vài tỉ lần mỗi giây.

Lộ trình: mọi thứ là bit, số âm được viết thế nào, vì sao 0.1 + 0.2 không bằng 0.3, từ công tắc tới cổng logic, tự lắp một bộ cộng chỉ bằng cổng NAND, xung nhịp là gì và vì sao nó giới hạn tốc độ, đo xung nhịp của CPU bằng chính phép cộng, đọc lệnh máy add từng bit một, rồi xem 1 + 1 trong Python tốn hơn bao nhiêu.

Mọi con số trong bài đều đo trên máy thật, với Python 3.12 và trình biên dịch clang. Phần mã máy dùng tập lệnh arm64 (loại CPU trong phần lớn điện thoại và nhiều laptop); trên CPU x86 các lệnh có tên khác, nhưng ý tưởng giống hệt.

Mọi thứ là bit

Bên trong con chip, mỗi "con số" là một nhóm dây dẫn, mỗi dây ở một trong hai trạng thái: điện áp cao hoặc điện áp thấp. Gọi chúng là 1 và 0, mỗi trạng thái là một bit. Tám bit là một byte, đủ cho 256 giá trị khác nhau.

Số 2 viết bằng bit là 10: một lần 2, không lần 1. Trong bộ nhớ, một số nguyên 64-bit chiếm 8 byte:

print(bin(2))                            # 0b10
print((2).to_bytes(8, "little").hex(" "))  # 02 00 00 00 00 00 00 00

Byte chứa phần nhỏ nhất đứng trước (kiểu "little-endian", cách gần như mọi CPU phổ biến hiện nay lưu số). Không có gì sâu xa ở đây; đó chỉ là một quy ước mà phần cứng và phần mềm phải thống nhất.

Số âm: bù hai

Không có dây nào mang "dấu trừ". Cách gần như mọi CPU viết số âm gọi là bù hai (two's complement): với 8 bit, −1 là 11111111, −2 là 11111110.

print((-1).to_bytes(1, "little", signed=True).hex())  # ff
print(f"{-2 & 0xff:08b}")                              # 11111110

Lý do chọn cách viết kỳ lạ này: với bù hai, phép cộng số âm dùng đúng mạch cộng số dương. 11111111 (−1) cộng 00000001 (1) cho ra 1 00000000; bit thứ chín rơi ra ngoài, còn lại 00000000 = 0. Đúng. CPU không cần một mạch riêng cho số âm.

Cái giá là giới hạn. Một số 8-bit có dấu chỉ chứa được từ −128 tới 127. Cộng thêm 1 vào 127:

import ctypes
print(ctypes.c_int8(127 + 1).value)  # -128

01111111 + 1 = 10000000, mà theo bù hai thì đó là −128. Đây là tràn số (overflow), và nó đã gây ra không ít sự cố phần mềm có thật. Python tránh được nhờ một mẹo: số nguyên của Python không có kích thước cố định. Số 1 chiếm 28 byte (phần lớn là phần đầu object, như bài Bên trong CPython đã xem), còn 2**64 chiếm 36 byte; số lớn đến đâu Python cũng cấp thêm chỗ đến đó.

Số thực: vì sao 0.1 + 0.2 ≠ 0.3

print(0.1 + 0.2)         # 0.30000000000000004
print(0.1 + 0.2 == 0.3)  # False

Số thực trong máy tính dùng chuẩn IEEE 754: 64 bit chia làm ba phần, như cách viết số theo kiểu khoa học (1,6 × 2⁻⁴):

0.1 = 0 01111111011 1001100110011001100110011001100110011001100110011010
      ↑ ↑           ↑
      │ │           phần định trị (52 bit): phần lẻ của 1,6 (số 1 đứng đầu được ngầm hiểu)
      │ số mũ (11 bit): −4
      dấu (1 bit): dương

Vấn đề nằm ở phần định trị. 1/10 trong hệ nhị phân là một số thập phân vô hạn tuần hoàn, 0,0001100110011…, giống như 1/3 = 0,333… trong hệ thập phân. 52 bit phải cắt ở đâu đó, nên số mà máy thực sự lưu không phải 0,1:

from decimal import Decimal
print(Decimal(0.1))        # 0.1000000000000000055511151231257827021181583404541015625
print(Decimal(0.1 + 0.2))  # 0.3000000000000000444089209850062616169452667236328125

Mỗi số đều sai một chút, ở khoảng chữ số thứ 17–18 sau dấu phẩy, và hai sai số cộng lại vừa đủ để lệch khỏi số gần 0,3 nhất. Đây không phải lỗi của Python; mọi ngôn ngữ dùng số thực IEEE 754 đều cho ra cùng kết quả. Hệ quả thực tế: đừng so sánh số thực bằng ==, và đừng dùng số thực cho tiền (hãy dùng số nguyên đồng, hoặc Decimal).

Từ công tắc tới cổng logic

Thành phần cơ bản của con chip là transistor: một công tắc điều khiển bằng điện. Điện áp ở một chân quyết định dòng điện có chảy qua hai chân còn lại hay không. Một con chip hiện đại có hàng chục tỉ cái như vậy.

Ghép vài transistor lại thành một cổng logic. Cổng quan trọng nhất là NAND ("not and"): đầu ra bằng 0 khi và chỉ khi cả hai đầu vào bằng 1. Trong công nghệ chip phổ biến (CMOS), một cổng NAND cần 4 transistor. Điều đặc biệt của NAND là mọi mạch logic đều có thể ghép từ NAND. Không cần tin lời; hãy viết ra:

COUNT = {"nand": 0}


def nand(a: int, b: int) -> int:
    """The only gate we allow ourselves: 0 only when both inputs are 1."""
    COUNT["nand"] += 1
    return 0 if (a and b) else 1


def not_(a):    return nand(a, a)
def and_(a, b): return not_(nand(a, b))
def or_(a, b):  return nand(not_(a), not_(b))
def xor(a, b):
    n = nand(a, b)
    return nand(nand(a, n), nand(b, n))
  • NOT là NAND với hai đầu vào giống nhau.
  • AND là NOT của NAND.
  • OR: "a hoặc b" bằng "không phải (không a và không b)".
  • XOR (khác nhau thì ra 1) ghép từ 4 cổng NAND.

Lắp một bộ cộng

Cộng hai số nhị phân giống hệt cộng số thập phân bằng tay: cộng từng cột từ phải sang, nhớ sang cột kế tiếp. Ở mỗi cột có ba bit vào (bit của a, bit của b, và bit nhớ từ cột trước) và hai bit ra (bit tổng và bit nhớ sang cột sau). Mạch làm việc đó gọi là bộ cộng đầy đủ (full adder):

def full_adder(a: int, b: int, carry_in: int) -> tuple[int, int]:
    """Add three bits: returns (sum bit, carry out)."""
    s1 = xor(a, b)
    total = xor(s1, carry_in)
    carry_out = or_(and_(a, b), and_(s1, carry_in))
    return total, carry_out


def add(x: int, y: int, bits: int = 8, trace: bool = False) -> int:
    """Ripple-carry adder: one full adder per bit, the carry passed along like a bucket brigade."""
    carry, out = 0, 0
    for i in range(bits):
        a, b = (x >> i) & 1, (y >> i) & 1
        s, new_carry = full_adder(a, b, carry)
        if trace:
            print(f"  bit {i}: a={a} b={b} carry_in={carry} -> sum={s} carry_out={new_carry}")
        out |= s << i
        carry = new_carry
    return out  # the final carry falls off the end: that's overflow

Xếp 8 bộ cộng đầy đủ thành một hàng, bit nhớ của cái này nối vào cái kế tiếp, là được một bộ cộng 8-bit. Giờ thì 1 + 1:

1 + 1 on an 8-bit adder:
  bit 0: a=1 b=1 carry_in=0 -> sum=0 carry_out=1
  bit 1: a=0 b=0 carry_in=1 -> sum=1 carry_out=0
  bit 2: a=0 b=0 carry_in=0 -> sum=0 carry_out=0
  ...
  bit 7: a=0 b=0 carry_in=0 -> sum=0 carry_out=0
  result 00000010 = 2

Đó là toàn bộ phép tính: ở cột 0, 1 + 1 = 0 nhớ 1; ở cột 1, 0 + 0 + nhớ 1 = 1. Kết quả 00000010, tức 2. Không có gì "hiểu" con số ở đây, chỉ có cổng NAND bật và tắt.

Vài con số về mạch vừa lắp:

Cổng NAND
Một bộ cộng đầy đủ (cách ghép ở trên) 15
Bộ cộng 8-bit 120
Bộ cộng 64-bit 960

(Ghép khéo hơn thì một bộ cộng đầy đủ chỉ cần 9 cổng NAND; cách ở trên dễ đọc hơn.) Chạy thử mọi cặp số 8-bit, cả 65.536 phép cộng đều đúng (theo modulo 256). Và 255 + 1 cho ra 0: bit nhớ cuối cùng rơi khỏi đầu bên trái, đúng là tràn số ở mục trước, bây giờ thấy tận mắt.

Xung nhịp: nhịp tim của con chip

Mạch cộng ở trên không cho ra kết quả ngay lập tức. Mỗi cổng cần một khoảng thời gian rất nhỏ để đầu ra đổi theo đầu vào, và trong bộ cộng kiểu "chuyền tay" này, bit nhớ phải đi qua lần lượt từng cột: cột 63 chỉ có kết quả đúng sau khi bit nhớ đã chạy qua 63 cột trước nó.

Vậy CPU biết lúc nào kết quả đã "xong"? Nó không biết. Thay vào đó, cả con chip chạy theo một xung nhịp (clock): một tín hiệu dao động đều đặn giữa 0 và 1, thường bắt nguồn từ một tinh thể thạch anh rồi được một mạch PLL trên chip nhân lên tần số cao. Ở mỗi cạnh lên của xung nhịp, các thanh ghi (những ô nhớ nhỏ nhất, nằm ngay trong lõi CPU) chốt lại giá trị đang có ở đầu vào của chúng. Giữa hai lần chốt, dòng điện chạy qua các cổng logic và phải kịp ổn định trước cạnh lên kế tiếp.

xung nhịp:  _|‾‾|__|‾‾|__|‾‾|__|‾‾|_
               ↑     ↑     ↑     ↑     mỗi cạnh lên: các thanh ghi chốt giá trị mới
               └─────┘
               một chu kỳ: mạch logic phải ổn định trong khoảng này

Xung nhịp 4,5 GHz nghĩa là 4,5 tỉ chu kỳ mỗi giây, mỗi chu kỳ khoảng 0,22 nano giây. Trong khoảng thời gian đó, ánh sáng trong chân không chỉ đi được khoảng 6,6 cm. Mọi việc trong một chu kỳ phải xảy ra trong khoảng thời gian ánh sáng đi hết chiều rộng một chiếc điện thoại.

Điều này giải thích hai thứ:

  • Vì sao CPU thật không dùng bộ cộng "chuyền tay". Để bit nhớ chạy qua 64 cột thì quá chậm. Bộ cộng trong CPU thật tính trước bit nhớ cho từng nhóm cột song song (carry-lookahead và các biến thể), để số tầng cổng mà tín hiệu phải đi qua chỉ tăng theo log₂ của số bit: khoảng 6 tầng cho 64 bit thay vì 64. Tốn nhiều cổng hơn, đổi lại kịp xong trong một chu kỳ.
  • Vì sao không thể tăng xung nhịp mãi. Chu kỳ càng ngắn thì mạch càng ít thời gian để ổn định, và mỗi lần chuyển trạng thái tốn năng lượng, sinh nhiệt. Từ giữa những năm 2000, xung nhịp gần như dừng ở khoảng vài GHz; CPU nhanh lên chủ yếu bằng cách làm nhiều việc hơn trong mỗi chu kỳ (mục kế tiếp) và có nhiều lõi hơn.

Đo xung nhịp bằng phép cộng

Có một cách đo xung nhịp mà không cần hỏi hệ điều hành: ra lệnh cho CPU làm một chuỗi phép cộng phụ thuộc nhau, mỗi phép cần kết quả của phép trước (x = x + 1, rồi lại x = x + 1…). Trên CPU hiện đại, một phép cộng số nguyên có độ trễ đúng một chu kỳ, nên số phép cộng mỗi giây chính là số chu kỳ mỗi giây.

Viết bằng C với hợp ngữ nội tuyến, để trình biên dịch không "khôn" mà gộp 100 phép cộng thành một phép nhân:

// Estimate the clock: a chain of dependent adds runs at one add per cycle.
#include <stdio.h>
#include <stdint.h>
#include <time.h>

#define ADD10 "add %0, %0, %1\n\t" "add %0, %0, %1\n\t" "add %0, %0, %1\n\t" "add %0, %0, %1\n\t" "add %0, %0, %1\n\t" \
              "add %0, %0, %1\n\t" "add %0, %0, %1\n\t" "add %0, %0, %1\n\t" "add %0, %0, %1\n\t" "add %0, %0, %1\n\t"

static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec * 1e-9; }

int main(void) {
    const long loops = 20000000;  // x 100 adds = 2 billion adds
    uint64_t x = 0, one = 1;
    double best = 1e9;
    for (int run = 0; run < 3; run++) {
        double t = now();
        for (long i = 0; i < loops; i++)
            __asm__ volatile(ADD10 ADD10 ADD10 ADD10 ADD10 ADD10 ADD10 ADD10 ADD10 ADD10 : "+r"(x) : "r"(one));
        t = now() - t; if (t < best) best = t;
    }
    printf("dependent: %.2f billion adds/s (x=%llu)\n", loops * 100 / best / 1e9, (unsigned long long)x);
}

Thêm một phiên bản thứ hai, cũng 100 phép cộng mỗi vòng nhưng chia cho bốn biến độc lập (a, b, c, d), để các phép cộng không phải chờ nhau. Kết quả (lấy lần nhanh nhất trong 3 lần, mỗi lần 2 tỉ phép cộng):

Phép cộng mỗi giây Ý nghĩa
Phụ thuộc nhau 4,57 tỉ ≈ xung nhịp: khoảng 4,6 GHz
Bốn chuỗi độc lập 13,26 tỉ ≈ 2,9 phép cộng mỗi chu kỳ

Con số đầu là xung nhịp của lõi này lúc đang chạy hết tốc lực (CPU hiện đại tự đổi xung nhịp theo tải và nhiệt độ, nên lúc rảnh nó chạy chậm hơn). Con số thứ hai thú vị hơn: một lõi CPU có nhiều bộ cộng (nhiều ALU) chạy song song, và khi các lệnh không phụ thuộc nhau, nó tự phát hiện ra và chạy chúng cùng một chu kỳ. Phép cộng thì như nhau; khác nhau chỉ ở chỗ lệnh này có phải chờ lệnh kia không. Đây là chủ đề của bài sau: CPU tìm việc song song trong một chương trình tuần tự như thế nào.

1 + 1 bằng mã máy

Một hàm C cộng hai số:

int add(int a, int b) { return a + b; }

biên dịch ra đúng một lệnh, cộng với lệnh trả về:

0: 0b000020   add  w0, w1, w0
4: d65f03c0   ret

Lệnh add là bốn byte 0b000020. Viết ra từng bit, mỗi nhóm bit có một nghĩa:

0b000020 = 0 00 01011 00 0 00000 000000 00001 00000
           │ │  │     │  │ │     │      │     └ Rd = 0: ghi kết quả vào thanh ghi w0
           │ │  │     │  │ │     │      └ Rn = 1: toán hạng thứ nhất từ w1
           │ │  │     │  │ │     └ dịch toán hạng thứ hai 0 bit
           │ │  │     │  │ └ Rm = 0: toán hạng thứ hai từ w0
           │ │  │     └──┴ kiểu dịch = 00, bit 21 = 0
           │ │  └ 01011: nhóm lệnh "cộng/trừ giữa hai thanh ghi"
           │ └ 00: cộng (không phải trừ), không cập nhật cờ
           └ 0: số 32-bit

Theo quy ước gọi hàm của arm64, a nằm trong thanh ghi w0 và b trong w1, và giá trị trả về cũng phải nằm trong w0. Nên add w0, w1, w0 làm đúng w0 = b + a.

Khi CPU gặp bốn byte này, việc xảy ra là:

  1. Lấy lệnh (fetch): đọc 4 byte từ bộ nhớ (thực ra là từ cache, bài sau).
  2. Giải mã (decode): các dây mang bit 24–28 (01011) bật đúng phần mạch dành cho phép cộng; bit 5–9 và 16–20 chọn hai thanh ghi để đọc.
  3. Thực thi (execute): giá trị của hai thanh ghi đi vào bộ cộng, chính là bộ cộng ở trên, chỉ là rộng hơn và nhanh hơn.
  4. Ghi lại (write back): ở cạnh lên kế tiếp của xung nhịp, kết quả được chốt vào thanh ghi w0.

Bốn bước đó không làm trong một chu kỳ. CPU chia chúng thành một dây chuyền (pipeline): trong lúc lệnh này đang thực thi, lệnh sau đang được giải mã, và lệnh sau nữa đang được lấy. Mỗi lệnh mất vài chu kỳ để đi hết dây chuyền, nhưng mỗi chu kỳ có lệnh mới đi ra ở đầu kia. Đó là cách một phép cộng có "độ trễ 1 chu kỳ" dù có cả chục bước.

Còn trong Python?

import dis
dis.dis(compile("x + y", "", "eval"))
LOAD_NAME   0 (x)
LOAD_NAME   1 (y)
BINARY_OP   0 (+)
RETURN_VALUE

BINARY_OP không phải là lệnh add của CPU. Nó là một đoạn code C trong trình thông dịch CPython: kiểm tra kiểu của hai object, tìm hàm cộng của kiểu int, đọc giá trị từ bên trong hai object, cộng, rồi tạo (hoặc lấy lại) một object kết quả. Đo thời gian:

x + y trong Python 3.12 Thời gian Khoảng bao nhiêu chu kỳ
1 + 1 4,1 ns ~19
0.1 + 0.2 6,0 ns ~27
10**30 + 10**30 10,0 ns ~46

(Thời gian đo bằng timeit, đã gồm cả chi phí của vòng lặp đo.) Lệnh add của phần cứng mất 1 chu kỳ; 1 + 1 trong Python mất khoảng 19 chu kỳ, vì phần lớn công việc là quản lý object chứ không phải phép cộng. Với số lớn, Python cộng từng khúc 30 bit một, như ta cộng từng chữ số trên giấy. Đây không phải lý do để bỏ Python; 4 nano giây vẫn là rất nhanh. Nhưng nó giải thích vì sao các thư viện tính toán như NumPy hay PyTorch (bài 2 của seri LLM) gom hàng triệu phép tính vào một lần gọi xuống code C: để trả phí "quản lý object" một lần cho cả mảng, thay vì một lần cho mỗi con số.

Tóm lại

Khái niệm Thực chất
Bit Một dây dẫn ở điện áp cao hoặc thấp
Bù hai Cách viết số âm để phép cộng số âm dùng chung mạch với số dương
Tràn số Bit nhớ cuối cùng rơi ra ngoài: 127 + 1 = −128 với 8 bit
IEEE 754 Dấu + số mũ + định trị; 0,1 là số tuần hoàn trong hệ nhị phân, nên bị làm tròn
Transistor và NAND Công tắc điều khiển bằng điện; 4 transistor thành một NAND; mọi mạch ghép được từ NAND
Bộ cộng Một bộ cộng đầy đủ mỗi bit; 1 + 1 = 0 nhớ 1, rồi 0 + 0 + nhớ 1 = 1
Xung nhịp Nhịp chốt của các thanh ghi; mạch phải ổn định trong một chu kỳ (~0,22 ns ở 4,5 GHz)
Đo xung nhịp Chuỗi cộng phụ thuộc: ~4,6 tỉ/giây; độc lập: ~2,9 phép mỗi chu kỳ
Lệnh máy add w0, w1, w0 là 4 byte; các nhóm bit chọn phép tính và thanh ghi
Python BINARY_OP ≈ 19 chu kỳ cho 1 + 1: phần lớn là quản lý object

Bài sau đi lên một tầng: CPU làm gì để chạy gần 3 lệnh mỗi chu kỳ từ một chương trình viết tuần tự, vì sao nó phải đoán trước câu lệnh if sẽ rẽ hướng nào, và vì sao đọc một con số từ RAM có thể chậm hơn cả trăm phép cộng.