Kodokon kodokon.com

ประสิทธิภาพ: การทำโปรไฟล์, GIL, generator เทียบกับลิสต์ และกับดักหน่วยความจำ

วัดก่อนปรับแต่ง: timeit และ cProfile, สิ่งที่ GIL บล็อกจริง ๆ, ความประหยัดของ generator และกับดักหน่วยความจำของสตริงกับสไลซ์

11 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

อย่าเดา จงวัด timeit แยกไมโครเบนช์มาร์กที่เชื่อถือได้ออกมา มันปิดการทำงานของ garbage collector และรันซ้ำหลายครั้งเพื่อกลบสัญญาณรบกวน ส่วน cProfile วาดแผนที่การเรียกของทั้งโปรแกรม เรียงลำดับตาม cumtime (เวลาสะสม รวมการเรียกย่อยด้วย) เพื่อระบุตัวการที่แท้จริง กฎทองของงานด้านประสิทธิภาพทุกครั้งคือ บรรทัดที่ช้าแทบไม่เคยเป็นบรรทัดที่คุณสงสัย

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
ออปชัน -s ของ timeit เตรียมบริบทไว้นอกการวัดผล

GIL (Global Interpreter Lock) รับประกันว่ามีเพียงเธรดเดียวเท่านั้นที่รันไบต์โค้ดของ Python ได้ในแต่ละขณะภายในโปรเซสหนึ่ง ผลที่ตามมาคือ เธรดไม่ได้ทำให้การคำนวณล้วน ๆ เร็วขึ้น อย่างไรก็ตาม พวกมันยังเหมาะอย่างยิ่งกับงาน I/O เพราะ GIL จะถูก ปล่อย ระหว่างการอ่านข้อมูลจากเครือข่ายหรือดิสก์ รวมถึงถูกปล่อยโดยไลบรารีภาษา C จำนวนมาก เช่น NumPy หากต้องการใช้งานหลายคอร์ให้เต็มที่ด้วยการคำนวณ ให้ไปทาง multiprocessing หรือ concurrent.futures.ProcessPoolExecutor ส่วน Python 3.13 มีเวอร์ชันทดลองแบบ "free-threaded" ที่ไม่มี GIL ซึ่งยังพบได้ยากในโปรดักชัน

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
ประมาณ 8 MB สำหรับลิสต์ ประมาณ 200 ไบต์สำหรับ generator

generator หนักเพียงไม่กี่ร้อยไบต์ ไม่ว่าลำดับจะยาวเท่าไรก็ตาม มันเก็บเพียงสถานะการทำงานของตัวเอง และผลิตค่าแต่ละค่าเมื่อมีการร้องขอ จงต่อพวกมันเข้าเป็น pipeline - sum(x * x for x in data) - เพื่อประมวลผลสตรีมโดยไม่ต้องสร้างลิสต์ตัวกลางขึ้นมาจริง ๆ เลย ข้อแลกเปลี่ยนที่ต้องรู้ไว้คือ generator ใช้ได้ครั้งเดียว เมื่อหมดสิ้นแล้ว มันจะไม่ผลิตอะไรอีกเลยอย่างเงียบ ๆ - เป็นต้นตอคลาสสิกของบั๊กที่การวนซ้ำรอบที่สองดู "ว่างเปล่า"

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
วัดผลบนเครื่องของคุณเอง join ยังคงเป็นตัวเลือกที่ปลอดภัยและใช้ได้ทุกที่

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. จริง ๆ แล้ว GIL ป้องกันอะไรกันแน่?
    • การทำงานแบบขนานทุกรูปแบบใน Python รวมถึง multiprocessing
    • การรันไบต์โค้ดของ Python พร้อมกันโดยหลายเธรดในโปรเซสเดียวกัน - งาน I/O และโค้ด C จำนวนมากปล่อยมัน
    • การใช้หลายเธรดในโปรแกรมเดียว
    • การเขียนพร้อมกันลงในตัวแปรเดียวกัน
  2. ทำไม sys.getsizeof ของ generator จึงเล็กจิ๋วเมื่อเทียบกับลิสต์ที่เทียบเท่ากัน?
    • generator ไม่ได้เก็บสมาชิกเลย มันเก็บเพียงสถานะการทำงานของตัวเอง และคำนวณค่าแต่ละค่าเมื่อมีการร้องขอ
    • getsizeof วัดขนาดของ generator ไม่ได้
    • generator บีบอัดข้อมูลของมันไว้ภายใน
  3. ทำไม ''.join(parts) จึงชนะ out += part ที่ทำซ้ำในลูป?
    • join ใช้หลายเธรดในการประกอบสตริง
    • มันเป็นเรื่องเล่าลือ ประสิทธิภาพเหมือนกันมาตั้งแต่ Python 3 แล้ว
    • เนื่องจาก str ไม่เปลี่ยนแปลง ทุก += จึงคัดลอกสตริงทั้งก้อนใหม่ (ต้นทุนกำลังสอง) ส่วน join คำนวณขนาดสุดท้ายแล้วจองหน่วยความจำเพียงครั้งเดียว
    • join หลีกเลี่ยงการสร้างอ็อบเจกต์ str ตัวกลางของ generator