DuckDB 1.4.4 sắp xếp 900.000 dòng từ một CSV 156,4 MB và ghi đủ kết quả ra Parquet với memory_limit='64 MiB'. Trong hai lượt đo ở mức này, thư mục tạm đạt đỉnh 166,91–176,75 MiB. Hai lượt ở mức 512 MiB cho cùng kết quả và ghi nhận đỉnh thư mục tạm bằng 0.
Mức 64 MiB là ngân sách của bộ quản lý bộ đệm DuckDB. Tiến trình chạy truy vấn đạt RSS khoảng 253–258 MiB, nên cần đo riêng RAM thực tế khi đặt ngân sách cho cả ứng dụng. Ví dụ này dùng CSV lớn hơn ngân sách bộ đệm đã đặt, trên cùng một máy macOS arm64. Tài liệu DuckDB 1.4 về memory_limit
Tại sao chọn sắp xếp toàn bộ tệp?
Đọc CSV theo từng khối và tính số dòng có thể hoàn tất mà chỉ giữ ít trạng thái. ORDER BY phải đọc toàn bộ đầu vào trước khi biết dòng nào đứng đầu. DuckDB có thể đưa dữ liệu trung gian của phép sắp xếp vào tệp tạm khi ngân sách bộ nhớ không đủ. Cơ chế này thường được gọi là spill to disk. Hướng dẫn DuckDB 1.4 về xử lý ngoài bộ nhớ
Thí nghiệm tạo CSV có 900.000 dòng và ba cột. id là số thứ tự từ 0, score được tính bằng phép lấy dư để đầu vào không có sẵn thứ tự cần tìm, và payload là chuỗi hex dài 160 ký tự sinh từ id. Bộ sinh xác định hoàn toàn theo công thức trong mã. Tệp thu được có 156.388.948 byte, tương đương 149,14 MiB.
Cả hai cấu hình đều dùng một luồng DuckDB. Câu lệnh COPY đọc CSV với schema khai báo sẵn, chạy ORDER BY score, id trên cả 900.000 dòng, rồi xuất ba cột thành một tệp Parquet nén Snappy. id phân định thứ tự khi nhiều dòng có cùng score. Chương trình kiểm tra cây profile có toán tử ORDER_BY và số dòng COPY trả về bằng số dòng đầu vào. Python chỉ nhận số dòng đã ghi ở bước đo. Đọc CSV trong DuckDB 1.4 · Đọc và ghi Parquet trong DuckDB 1.4
Đọc ba số đo bộ nhớ khác nhau
Mỗi lượt chạy dùng một tiến trình con và một kết nối mới. Cấu hình profiling_coverage='ALL' đưa lệnh COPY vào phạm vi đo, rồi enable_profiling='json' ghi cây thực thi và các số đo vào tệp. Thư mục tạm được đặt riêng cho từng lượt. Tài liệu DuckDB 1.4 về profiling
| Số đo | Ý nghĩa trong thí nghiệm |
|---|---|
system_peak_buffer_memory |
Đỉnh bộ nhớ của bộ quản lý bộ đệm DuckDB, lấy từ JSON profile. |
system_peak_temp_dir_size |
Đỉnh dung lượng thư mục tạm mà DuckDB ghi nhận trong lượt chạy. Đây là mức dung lượng tại một thời điểm, không cộng dồn số byte đã ghi qua các lần đọc và ghi tệp. Tệp Parquet đầu ra nằm ở đường dẫn riêng. |
| Peak RSS | Đỉnh lượng bộ nhớ vật lý thường trú của toàn tiến trình con, gồm Python và các cấp phát ngoài bộ quản lý bộ đệm. |
Chương trình đọc ru_maxrss của tiến trình con ngay sau khi COPY hoàn tất. Darwin trả số byte. Linux trả số KiB, nên mã nhân 1.024 trước khi báo số byte. Mỗi tiến trình chạy một phép sắp xếp, giúp phép đo RSS không mang theo đỉnh của lượt trước. Tài liệu Python về getrusage · getrusage trên Darwin · getrusage trên Linux
Kết quả trên CSV 900.000 dòng
Đo với DuckDB 1.4.4, Python 3.10.11 trên macOS arm64. Thứ tự chạy là 64, 512, 512 rồi 64 MiB. Các cột dung lượng dùng MiB, mỗi MiB bằng 1.048.576 byte.
| Lượt | memory_limit |
Thời gian COPY (giây) | Đỉnh bộ đệm (MiB) | Đỉnh thư mục tạm (MiB) | Peak RSS (MiB) |
|---|---|---|---|---|---|
| 1 | 64 MiB | 1,5841 | 63,95 | 176,75 | 252,89 |
| 2 | 512 MiB | 0,9077 | 209,62 | 0 | 363,12 |
| 3 | 512 MiB | 0,9282 | 209,62 | 0 | 385,08 |
| 4 | 64 MiB | 1,4498 | 63,95 | 166,91 | 258,45 |
Ở 64 MiB, đỉnh bộ đệm áp sát mức đặt và DuckDB dùng thư mục tạm. Ở 512 MiB, bộ đệm đạt đỉnh 209,62 MiB và profile không ghi nhận sử dụng thư mục tạm. Trên phép đo này, hai lượt 64 MiB dùng ít RSS hơn và mất nhiều thời gian hơn hai lượt 512 MiB. Đỉnh RSS cao hơn đỉnh bộ đệm ở cả bốn lượt. Hai số đo có thể đạt đỉnh ở các thời điểm khác nhau, nên hiệu giữa chúng không phải số byte chính xác cấp phát ngoài bộ đệm.
Đồng hồ đo từ lúc gọi COPY đến khi nhận số dòng đã ghi. Khoảng này bao gồm đọc và chuyển kiểu CSV, sắp xếp, nén và ghi Parquet cùng chi phí profiling. Tạo dữ liệu, khởi động tiến trình con và kiểm tra đầu ra nằm ngoài đồng hồ. Tệp nguồn được tạo ngay trước khi đo và bộ nhớ đệm của hệ điều hành không bị xóa giữa các lượt. Thời gian có thể đổi theo ổ đĩa, cache và tải hệ thống. Bốn số đo đủ để nhìn thấy sự đánh đổi trong ví dụ, chưa đủ để dự báo tỷ lệ chậm hơn cho tệp hoặc truy vấn khác.
Xác minh bốn tệp Parquet cho cùng dữ liệu và thứ tự
Mỗi tệp đầu ra có 900.000 dòng và 149.870.351 byte. Sau khi hoàn tất phép đo, chương trình đọc lại từng tệp trong một luồng và giữ thứ tự đọc. Nó chuyển tối đa 8.192 dòng mỗi lần sang Python, kiểm tra từng cặp (score, id) tăng nghiêm ngặt và đối chiếu score với công thức sinh dữ liệu. Tài liệu DuckDB 1.4 về giữ thứ tự khi đọc tệp
Tổng id của mỗi tệp là 404.999.550.000. Mã cũng tính SHA-256 trên các dòng đã đọc, theo đúng thứ tự và gồm cả payload. Cả bốn lượt cho cùng chữ ký 2b91dfdf5445a6538b679c581355db021b58c404fefd59d18b4e7302a9577a54. Kiểm tra nội dung này giúp phát hiện trường hợp hai tệp có cùng kích thước nhưng khác dòng hoặc khác thứ tự.
Khi dùng cách đo này cho dữ liệu thật, nên giữ cố định câu truy vấn, schema, định dạng đầu ra và số luồng trước khi đổi memory_limit. Xem cùng lúc đỉnh bộ đệm, RSS, dung lượng tệp tạm và tính đúng của đầu ra. Nếu phép đo có spill, temp_directory cần đủ chỗ cho dữ liệu trung gian ngoài chỗ chứa tệp đầu ra. Truy vấn có nhiều toán tử cần giữ trạng thái lớn, hoặc có phép tổng hợp chưa hỗ trợ ghi tạm, vẫn có thể hết bộ nhớ. Giới hạn xử lý ngoài bộ nhớ của DuckDB 1.4
Chạy lại thí nghiệm
Lưu mã dưới đây thành experiment.py, cài DuckDB 1.4.4 trong môi trường Python riêng và chạy. Chương trình hỗ trợ macOS và Linux. Thư mục tạm nên có ít nhất 2 GiB trống vì bốn tệp Parquet được giữ lại đến khi kiểm tra xong. Chương trình tự dọn thư mục thí nghiệm khi kết thúc.
1python3 -m venv .venv
2. .venv/bin/activate
3python3 -m pip install duckdb==1.4.4
4python3 experiment.py
1import hashlib
2import json
3import platform
4import resource
5import subprocess
6import sys
7import tempfile
8from pathlib import Path
9from time import perf_counter
10
11import duckdb
12
13
14ROWS = 900_000
15LIMITS_MIB = (64, 512, 512, 64)
16
17
18def quote(path):
19 return "'" + str(path).replace("'", "''") + "'"
20
21
22def make_csv(path):
23 with path.open("w", encoding="ascii", newline="") as out:
24 out.write("id,score,payload\n")
25 for row_id in range(ROWS):
26 score = (row_id * 104_729 + 12_345) % 100_003
27 payload = hashlib.shake_128(str(row_id).encode("ascii")).hexdigest(80)
28 out.write(f"{row_id},{score},{payload}\n")
29
30
31def has_sort(node):
32 return node.get("operator_type") == "ORDER_BY" or any(
33 has_sort(child) for child in node.get("children", [])
34 )
35
36
37def sort_trial(csv, run, limit_mib):
38 profile_path = run / "profile.json"
39 output_path = run / "sorted.parquet"
40 with duckdb.connect() as db:
41 db.execute("SET threads = 1")
42 db.execute("SET preserve_insertion_order = true")
43 db.execute(f"SET memory_limit = '{limit_mib} MiB'")
44 db.execute(f"SET temp_directory = {quote(run / 'spill')}")
45 db.execute("SET profiling_coverage = 'ALL'")
46 db.execute(f"SET profiling_output = {quote(profile_path)}")
47 db.execute("SET enable_profiling = 'json'")
48 sql = f"""
49 COPY (
50 SELECT id, score, payload
51 FROM read_csv(
52 {quote(csv)}, header=true, auto_detect=false, delim=',',
53 columns={{'id': 'BIGINT', 'score': 'INTEGER', 'payload': 'VARCHAR'}},
54 max_line_size=2048, buffer_size=32768
55 )
56 ORDER BY score, id
57 ) TO {quote(output_path)} (FORMAT PARQUET, COMPRESSION SNAPPY)
58 """
59 started = perf_counter()
60 written = db.execute(sql).fetchone()[0]
61 elapsed = perf_counter() - started
62 rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
63 rss_bytes = rss if sys.platform == "darwin" else rss * 1024
64 profile = json.loads(profile_path.read_text(encoding="utf-8"))
65 assert written == ROWS and has_sort(profile)
66 print(json.dumps({
67 "limit_mib": limit_mib,
68 "elapsed_s": round(elapsed, 4),
69 "peak_temp_bytes": profile["system_peak_temp_dir_size"],
70 "peak_buffer_bytes": profile["system_peak_buffer_memory"],
71 "peak_rss_bytes": rss_bytes,
72 "parquet_bytes": output_path.stat().st_size,
73 }))
74
75
76def verify_sorted(path):
77 digest = hashlib.sha256()
78 count = id_sum = 0
79 previous = (-1, -1)
80 with duckdb.connect() as db:
81 db.execute("SET threads = 1")
82 db.execute("SET preserve_insertion_order = true")
83 cursor = db.execute("SELECT id, score, payload FROM read_parquet(?)", [str(path)])
84 while batch := cursor.fetchmany(8192):
85 for row_id, score, payload in batch:
86 key = (score, row_id)
87 assert previous < key
88 assert 0 <= row_id < ROWS and score == (row_id * 104_729 + 12_345) % 100_003
89 previous = key
90 count += 1
91 id_sum += row_id
92 digest.update(f"{row_id},{score},{payload}\n".encode("ascii"))
93 assert count == ROWS and id_sum == ROWS * (ROWS - 1) // 2
94 return count, id_sum, digest.hexdigest()
95
96
97def main():
98 if sys.platform not in {"darwin", "linux"}:
99 raise SystemExit("This experiment supports macOS and Linux")
100 if duckdb.__version__ != "1.4.4":
101 raise SystemExit("Install duckdb==1.4.4")
102 print(f"DuckDB {duckdb.__version__}, Python {platform.python_version()}, {platform.system()} {platform.machine()}")
103 with tempfile.TemporaryDirectory(prefix="duckdb-sort-") as folder:
104 workspace = Path(folder)
105 csv = workspace / "source.csv"
106 make_csv(csv)
107 print(f"rows={ROWS}, csv_bytes={csv.stat().st_size}, threads=1")
108 print("run order (MiB):", LIMITS_MIB)
109 runs = []
110 for index, limit_mib in enumerate(LIMITS_MIB, start=1):
111 run = workspace / f"run-{index}"
112 run.mkdir()
113 result = subprocess.check_output(
114 [sys.executable, __file__, "--trial", str(csv), str(run), str(limit_mib)],
115 text=True,
116 )
117 print(f"trial={index} {result.strip()}")
118 runs.append(run / "sorted.parquet")
119 signatures = [verify_sorted(path) for path in runs]
120 assert all(signature == signatures[0] for signature in signatures)
121 print("all sorted outputs (count, sum(id), sha256):", signatures[0])
122
123
124if __name__ == "__main__":
125 if len(sys.argv) == 5 and sys.argv[1] == "--trial":
126 sort_trial(Path(sys.argv[2]), Path(sys.argv[3]), int(sys.argv[4]))
127 else:
128 main()
Đọc thêm
- Các bài toán cần giải sau khi dữ liệu đã sẵn sàng để truy vấn. Các phương pháp phân tích dữ liệu lớn
- Khi nhiều tệp Parquet cần trở thành một bảng có metadata và snapshot. Vai trò của Apache Iceberg trong Data Lake và Lakehouse
Bình luận