Mesin pipeline TokoKita hanya punya 4 GB RAM, dan suatu malam prosesnya berhenti dengan MemoryError setelah daftar pesanan menembus ratusan ribu objek. Chapter Memory Management membahas memori di level Spark — heap JVM, unified memory, spill otomatis; bab ini turun ke level Python murni: berapa byte sebenarnya dipakai list, DataFrame, dan objek, serta tiga tuas yang memangkasnya tanpa mengganti alat. Semua angka diukur dengan tracemalloc dari pustaka standar, bukan perkiraan.

Ukur Dulu dengan tracemalloc #

tracemalloc mencatat setiap alokasi memori Python yang hidup di dalam proses. Skrip pengukuran memakai helper puncak() yang membaca tracemalloc.get_traced_memory()[1] — angka kedua adalah puncak alokasi sejak start(). Bagian pertama membandingkan dua cara menjumlahkan 300 ribu pesanan: menampung seluruh record dalam list-of-dicts, atau mengalirkan record satu per satu lewat generator:

python
def buat_record(i: int) -> dict:
    return {"id": i, "kota": KOTA[i % 4], "qty": i % 10 + 1, "nilai": i * 250.0}


if __name__ == "__main__":
    print("=== Bagian 1: list-of-dicts vs streaming generator ===")
    tracemalloc.start()
    baris = [buat_record(i) for i in range(N)]
    total_semua = sum(r["nilai"] * r["qty"] for r in baris)
    print(f"list-of-dicts : puncak {puncak()} "
          f"(total nilai Rp{total_semua:,.0f})")
    tracemalloc.stop()

    def stream_record():
        for i in range(N):
            r = buat_record(i)
            yield r["nilai"] * r["qty"]

    tracemalloc.start()
    total_stream = sum(stream_record())
    print(f"generator     : puncak {puncak()} "
          f"(total nilai Rp{total_stream:,.0f})")
    tracemalloc.stop()

Bagian kedua menekan DataFrame satu juta baris dengan pd.to_numeric(downcast=...); konstruksi kolom int64/float64-nya dipangkas dari tampilan:

python
    sebelum = jumlah_mb(df)
    dtype_awal = dict(df.dtypes.astype(str))
    df["pesanan_id"] = pd.to_numeric(df["pesanan_id"], downcast="integer")
    df["qty"] = pd.to_numeric(df["qty"], downcast="integer")
    df["harga"] = pd.to_numeric(df["harga"], downcast="float")
    sesudah = jumlah_mb(df)
    print(f"sebelum downcast : {sebelum:6.1f} MB  {dtype_awal}")
    print(f"sesudah downcast : {sesudah:6.1f} MB  {dict(df.dtypes.astype(str))} "
          f"(hemat {(1 - sesudah / sebelum) * 100:.0f}%)")
    total_float32 = float(df["harga"].sum())
    total_exact = sum(15000.0 + (i % 500) * 100.0 for i in range(1_000_000))
    print(f"jumlah harga float32 vs float64 identik: "
          f"{total_float32 == total_exact} "
          f"(selisih Rp{abs(total_float32 - total_exact):,.0f})")

Potongan ketiga membandingkan read_csv utuh dengan mode chunksize — baris pembacaan utuhnya dipangkas dari tampilan, sedangkan angkanya terlihat pada output di bawah:

python
    tracemalloc.start()
    total_chunk = 0.0
    n_baris = 0
    for chunk in pd.read_csv(jalur, chunksize=100_000):
        total_chunk += chunk["harga"].sum()
        n_baris += len(chunk)
    print(f"read_csv chunked : puncak {puncak()} "
          f"({n_baris:,} baris, total Rp{total_chunk:,.0f})")
    tracemalloc.stop()

Output yang Dihasilkan:

text
=== Bagian 1: list-of-dicts vs streaming generator ===
list-of-dicts : puncak    71.1 MB (total nilai Rp61,875,412,500,000)
generator     : puncak       1 KB (total nilai Rp61,875,412,500,000)

=== Bagian 2: downcast dtype pada DataFrame 1 juta baris ===
sebelum downcast :   22.9 MB  {'pesanan_id': 'int64', 'qty': 'int64', 'harga': 'float64'}
sesudah downcast :    8.6 MB  {'pesanan_id': 'int32', 'qty': 'int8', 'harga': 'float32'} (hemat 62%)
jumlah harga float32 vs float64 identik: False (selisih Rp3,968)

=== Bagian 3: baca CSV utuh vs chunked ===
read_csv utuh    : puncak    45.8 MB (total Rp39,950,000,000)
read_csv chunked : puncak     7.6 MB (1,000,000 baris, total Rp39,950,000,000)

=== Bagian 4: jebakan sys.getsizeof ===
sys.getsizeof(daftar)      : 8.1 MB
tracemalloc alokasi total  :    41.9 MB (daftar + 1 juta objek int)

=== Bagian 5: objek biasa vs __slots__ vs array ===
100 ribu objek __dict__ : puncak    11.4 MB
100 ribu objek __slots__: puncak     8.4 MB
array kolom ('q'+'b')   : puncak     1.6 MB (100,000 elemen per kolom)

Mari kita telaah bagian-bagian penting kode di atas:

  1. Baris 36 (list comprehension): 300 ribu dict dialokasikan sekaligus; tiap dict membawa overhead hash table dan pointer, sehingga data sederhana membengkak menjadi 71,1 MB.
  2. Baris 42-45 (stream_record): generator menyimpan satu record pada satu waktu; yield mengeluarkan nilai, lalu record sebelumnya boleh dikumpulkan pemulung memori — puncaknya tinggal 1 KB dengan total yang sama.
  3. Baris 63-65 (downcast): pd.to_numeric menurunkan int64 ke int32/int8 setelah memeriksa rentang nilai; 22,9 MB turun ke 8,6 MB.
  4. Baris 70-74 (uji presisi): jumlah harga float32 dibandingkan dengan penjumlahan float64 — hasilnya tidak identik, selisih Rp3.968 dari total Rp39,95 miliar.
  5. Baris 91 (chunksize=100_000): pembacaan utuh memuat satu juta baris sekaligus (45,8 MB); mode chunk memuat 100 ribu baris per potongan sehingga puncaknya 7,6 MB.

Dua pengukuran penutup pada skrip yang sama (kodenya dirangkum di sini) melengkapi gambar: sys.getsizeof pada list satu juta integer hanya melaporkan 8,1 MB karena fungsi tersebut mengukur wadah, bukan isi — tracemalloc mencatat 41,9 MB termasuk objek integernya; dan 100 ribu objek dengan __slots__ memakai 8,4 MB versus 11,4 MB versi __dict__, sementara layout kolom memakai array hanya 1,6 MB.

TeknikPuncak memoriCatatan
list-of-dicts 300 ribu record71,1 MBgenerator: 1 KB untuk hasil identik
downcast int64 ke int32/int8, float64 ke float3222,9 MB menjadi 8,6 MBpresisi float menurun
read_csv utuh vs chunksize 100.00045,8 MB menjadi 7,6 MBhasil penjumlahan identik
sys.getsizeof vs tracemalloc8,1 MB vs 41,9 MByang pertama hanya mengukur wadah
__dict__ vs __slots__ vs array11,4 / 8,4 / 1,6 MBlayout kolom paling hemat

Presisi versus Ukuran #

Hemat 62% dari downcast bukan hadiah gratis. float32 menyimpan sekitar tujuh digit desimal signifikan, sehingga menjumlahkan satu juta harga ratusan ribu rupiah menggeser total Rp3.968. Untuk kolom uang, integer rupiah (int64) atau float64 tetap pilihan yang aman; downcast paling cocok untuk kolom dengan rentang kecil seperti qty (int8 menampung sampai 127) dan pesanan_id (int32 menampung sampai 2,1 miliar — cukup untuk pesanan TokoKita bertahun-tahun). Aturannya: turunkan lebar tipe hanya setelah memeriksa nilai maksimum kolomnya.

Kapan Menumpah ke Disk Secara Manual #

Bila dataset melebihi RAM, ada dua jalan. Jalan pertama menyerahkan urusan ke engine out-of-core: DuckDB menumpahkan data kerja ke disk secara otomatis (model eksekusinya dibandingkan di chapter Kapan Menggunakan Spark), dan mekanisme spill Spark dibedah di chapter Memory Management. Jalan kedua menumpah secara sadar di kode kita: potong data per chunk, agregasi parsial per chunk, lalu gabungkan hasil — pola yang akan dipakai lagi pada chapter Mengatasi Data Skew. Sortir global adalah kasus tersulit karena semua baris harus dibandingkan, sehingga pilihannya terbatas pada sortir per chunk lalu merge bertahap. Aturan praktisnya: bila agregasinya komutatif, partial aggregation per chunk hampir selalu menang.

Jebakan Salinan Tersembunyi #

Satu jebakan memori di pandas tidak terlihat dari angka MB: operasi berantai menghasilkan salinan sementara, dan penulisan di atas salinan tidak menempel ke DataFrame asli.

python
"""Demo ch9: jebakan chained assignment dan pembenarannya (.loc)."""
import warnings

import pandas as pd

df = pd.DataFrame({"qty": [1, 10, 3], "nilai": [100.0, 200.0, 300.0]})

print("--- cara salah: chained assignment ---")
with warnings.catch_warnings(record=True) as tangkap:
    warnings.simplefilter("always")
    df[df["qty"] > 5]["nilai"] = 0.0
    for w in tangkap:
        print(f"peringatan: {w.category.__name__}")
print(df.to_string(index=False))

print("--- cara benar: .loc sekali langkah ---")
df.loc[df["qty"] > 5, "nilai"] = 0.0
print(df.to_string(index=False))

Output yang Dihasilkan:

text
--- cara salah: chained assignment ---
peringatan: SettingWithCopyWarning
 qty  nilai
   1  100.0
  10  200.0
   3  300.0
--- cara benar: .loc sekali langkah ---
 qty  nilai
   1  100.0
  10    0.0
   3  300.0

Setelah cara salah, baris kedua masih bernilai 200.0: df[df["qty"] > 5] mengembalikan salinan, penulisan 0.0 mengenai salinan tersebut, lalu salinan dibuang. Selain hasil yang tidak berubah, alokasi salinan juga memboroskan memori dua kali pada baris terpilih. Satu langkah .loc menulis langsung ke DataFrame asli tanpa salinan. Referensi tracemalloc dan layout array ada di dokumentasi tracemalloc dan dokumentasi array .

Memori kini terukur dan terkendali, tetapi pipeline yang hemat memori pun bisa berjalan timpang ketika satu kunci menumpuk di satu worker. Ketimpangan beban inilah yang kita bedah pada chapter Mengatasi Data Skew.