داربست — خودآموز مهندسیِ سامانه‌های هوش مصنوعی (پیشرفته)

فصل ۷ از ۹

پیشرفت ترم
۰٪

ترم ۱ · کدی که می‌شود به آن تکیه کرد

سریع‌تر، بدونِ GPU

فصل ۷پیش‌نمایش رایگان
۱۴ دقیقه مطالعه فصل ۷

در این فصل چه یاد می‌گیری#

فصلِ قبل نقطهٔ داغ را پیدا کرد. این فصل سه ابزارِ سریع‌کردن را امتحان می‌کند و هر سه را با عدد می‌سنجد — چون دو تا از آن‌ها در جای اشتباه هیچ سودی ندارند.

بزرگ‌ترین عددِ این فصل: پیدا کردنِ نزدیک‌ترین همسایه با حلقهٔ پایتونی ششصد میلی‌ثانیه طول می‌کشد و با یک ضربِ ماتریسی زیرِ نیم میلی‌ثانیه — بیش از هزار برابر، با جوابِ مو به مو یکسان. و کوچک‌ترین عدد: یک cache که در جای درست ۱٫۴۷ برابر سود می‌دهد، روی ورودیِ بدون تکرار ۰٫۹۶ برابر می‌شود — یعنی کندتر.

دو مسیرِ تولید: یکی قطعه‌به‌قطعه، دیگری یک قالبِ گروهی

آخر این فصل می‌توانی:

  • یک حلقهٔ عددی را برداری کنی و ثابت کنی جواب عوض نشده
  • کپیِ بی‌جا را از کپیِ بی‌ضرر تشخیص بدهی، با عدد
  • بگویی نرخِ اصابتِ cache چطور سقفِ بهبودت را تعیین می‌کند
  • تصمیم بگیری کِی cache نگذاری

قبل از شروع#

از فصلِ ۶: «سقفِ بهبودِ تو، سهمِ همان بخش از کلِ زمان است». این فصل همان قاعده را یک لایه پایین‌تر می‌برد: سقفِ بهبودِ cache، نرخِ اصابتش است.

از سرنخ ترمِ ۲: numpy، ndarray، shape و کارِ برداری. اینجا فرض می‌کنیم بلدی و فقط اندازه می‌گیریم.

اجرا زمانِ تقریبی
CPU (پیش‌فرضِ Colab) حدودِ دو دقیقه

📓 نوت‌بوک: نوت‌بوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و به‌ترتیب داخلش هست.

۱. برداری‌سازی#

کاری که می‌سنجیم واقعی است: برای هر تیکت، شبیه‌ترین تیکتِ دیگر را پیدا کن. همان کاری که برای پیدا کردنِ تیکت‌های تکراری لازم داری.

import re
import time

import numpy as np
from sklearn.feature_extraction.text import CountVectorizer


def normalise(text):
    text = text.replace("ي", "ی").replace("ك", "ک")
    return re.sub(r"\s+", " ", text).strip()


def clock(fn, repeat=3):
    best = float("inf")
    for _ in range(repeat):
        start = time.perf_counter()
        result = fn()
        best = min(best, time.perf_counter() - start)
    return best, result


rows = TICKETS[:100]
texts = [normalise(r["text"]) for r in rows]
M = CountVectorizer().fit_transform(texts).toarray().astype(float)
print("ماتریسِ سند-واژه:", M.shape, "=", M.shape[0] * M.shape[1], "خانه")
ماتریسِ سند-واژه: (100, 176) = 17600 خانه
def nearest_loops(M):
    n = len(M)
    out = []
    for i in range(n):
        best_score, best_j = -1.0, -1
        for j in range(n):
            if i == j:
                continue
            dot = norm_i = norm_j = 0.0
            for a, b in zip(M[i], M[j]):
                dot += a * b
                norm_i += a * a
                norm_j += b * b
            score = dot / ((norm_i ** 0.5) * (norm_j ** 0.5))
            if score > best_score:
                best_score, best_j = score, j
        out.append(best_j)
    return out


loop_time, loop_result = clock(lambda: nearest_loops(M), repeat=1)
print(f"حلقهٔ پایتونی: {loop_time * 1000:.0f} میلی‌ثانیه")
print("چند نمونه از نزدیک‌ترین همسایه:", loop_result[:5])
حلقهٔ پایتونی: 617 میلی‌ثانیه
چند نمونه از نزدیک‌ترین همسایه: [79, 92, 24, 51, 28]

سه حلقهٔ تودرتو: صد سند در صد سند در ۱۷۶ ستون. حالا همان کار، بدونِ اینکه حتی یک حلقه بنویسیم:

def nearest_vectorised(M):
    norms = np.sqrt((M * M).sum(axis=1))
    similarity = (M @ M.T) / np.outer(norms, norms)
    np.fill_diagonal(similarity, -1.0)
    return similarity.argmax(axis=1).tolist()


vec_time, vec_result = clock(lambda: nearest_vectorised(M))
print(f"نسخهٔ برداری : {vec_time * 1000:.1f} میلی‌ثانیه")
print(f"بهبود        : {loop_time / vec_time:.0f} برابر")
print("همان جواب؟", vec_result == loop_result)
نسخهٔ برداری : 0.4 میلی‌ثانیه
بهبود        : 1699 برابر
همان جواب؟ True

بیش از هزار برابر، با جوابِ کاملاً یکسان.

و آن True بخشِ اجباریِ ماجراست، نه تزیین. برداری‌سازی یعنی بازنویسیِ منطق با شکلِ متفاوت، و هر بازنویسی می‌تواند رفتار را عوض کند. قاعده: هر بهینه‌سازی باید کنارِ خودش یک مقایسهٔ خروجی داشته باشد.

چرا این‌قدر تفاوت؟ حلقهٔ پایتونی برای هر ضرب یک شیءِ float می‌سازد و برمی‌گرداند، و مفسر برای هر گام کارِ زیادی می‌کند. M @ M.T همان ضرب‌ها را داخلِ کدِ کامپایل‌شدهٔ numpy انجام می‌دهد — بدونِ ساختنِ شیء، روی حافظهٔ پیوسته، و با دستورهایی که چند عدد را هم‌زمان حساب می‌کنند.

پس قاعدهٔ عملی: هر جا حلقه‌ای روی اعداد نوشتی، اول بپرس آیا numpy همان را در یک عبارت انجام می‌دهد. برای متن و شیء و منطقِ شرطی معمولاً نه؛ برای عدد تقریباً همیشه بله.

۲. کپی‌ای که فکر می‌کنی گران است#

مسیرِ فصلِ قبل سه بار داده را کپی می‌کرد. حالا بسنجیمش — و آماده باش که جواب کم‌هیجان باشد.

def three_copies(rows):
    a = [{**r, "text": normalise(r["text"])} for r in rows]
    b = [{**r, "text": r["text"].lower()} for r in a]
    c = [{**r, "words": r["text"].split()} for r in b]
    return c


def one_pass(rows):
    out = []
    for r in rows:
        text = normalise(r["text"]).lower()
        out.append({**r, "text": text, "words": text.split()})
    return out


big = [r for r in MESSY if isinstance(r["text"], str)]
print("ردیف‌ها:", len(big))
print("دیکشنری‌های ساخته‌شده — سه‌کپی:", 3 * len(big), "| یک‌گذر:", len(big))
print("خروجی یکی است؟", three_copies(big) == one_pass(big))
ردیف‌ها: 782
دیکشنری‌های ساخته‌شده — سه‌کپی: 2346 | یک‌گذر: 782
خروجی یکی است؟ True
import tracemalloc

for name, fn in [("سه کپی", three_copies), ("یک گذر", one_pass)]:
    tracemalloc.start()
    fn(big)
    _, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    spent, _ = clock(lambda: fn(big))
    print(f"  {name:<8} اوجِ حافظه {peak / 1024:>7.0f} کیلوبایت   زمان {spent * 1000:>6.1f} میلی‌ثانیه")
  سه کپی   اوجِ حافظه    1634 کیلوبایت   زمان    5.1 میلی‌ثانیه
  یک گذر   اوجِ حافظه    1188 کیلوبایت   زمان    4.7 میلی‌ثانیه

سه برابر دیکشنری، ولی فقط ۱٫۴ برابر حافظه و تقریباً همان زمان.

دلیلش مهم است: {**r, ...} کپیِ سطحی است. یک دیکشنریِ تازه می‌سازد ولی رشته‌ها را کپی نمی‌کند — همان اشیاء را دوباره اشاره می‌کند. پس هزینه‌اش فقط اسکلتِ دیکشنری است، نه محتوا.

این یک نتیجهٔ کم‌رمق است و باید همان‌طور گزارش شود. «سه بار کپی نکن» یک توصیهٔ درست‌نما بود که وقتی سنجیدیمش، ۱٫۴ برابر حافظه و صفر زمان درآمد. این‌طور توصیه‌ها را نگه دار ولی به‌عنوانِ عادتِ خوب، نه به‌عنوانِ بهینه‌سازی.

۳. کپی‌ای که واقعاً گران است#

حالا کپیِ واقعی — و این یکی مخصوصِ کارِ متنی است:

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

corpus = [normalise(r["text"]) for r in big]
labels = [r["label"] for r in big]
sparse = CountVectorizer().fit_transform(corpus)
dense = sparse.toarray()

sparse_bytes = sparse.data.nbytes + sparse.indices.nbytes + sparse.indptr.nbytes
print("شکل:", sparse.shape, "| خانه‌های ناصفر:", sparse.nnz,
      f"({sparse.nnz / (sparse.shape[0] * sparse.shape[1]):.2%})")
print(f"حافظهٔ نسخهٔ sparse: {sparse_bytes / 1024:>8.0f} کیلوبایت")
print(f"حافظهٔ نسخهٔ dense : {dense.nbytes / 1024:>8.0f} کیلوبایت")
print(f"نسبت              : {dense.nbytes / sparse_bytes:>8.0f} برابر")
شکل: (782, 596) | خانه‌های ناصفر: 10482 (2.25%)
حافظهٔ نسخهٔ sparse:      126 کیلوبایت
حافظهٔ نسخهٔ dense :     3641 کیلوبایت
نسبت              :       29 برابر

بیست‌ونه برابر — و ۹۸ درصدِ آن ماتریس صفر است.

هر تیکت حدودِ سیزده کلمه دارد و واژگانِ کل ۵۹۶ کلمه است، پس در هر سطر ۵۸۳ خانه صفر است. نسخهٔ sparse فقط خانه‌های ناصفر را نگه می‌دارد؛ toarray() تک‌تکِ آن صفرها را به‌شکلِ هشت بایتِ واقعی می‌نویسد.

و حالا سؤالی که این کپی را «بی‌جا» می‌کند:

scores = []
for matrix in (sparse, dense):
    X_tr, X_te, y_tr, y_te = train_test_split(
        matrix, labels, test_size=0.25, random_state=0, stratify=labels)
    model = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
    scores.append(round(model.score(X_te, y_te), 4))
print("دقت با sparse:", scores[0], "| دقت با dense:", scores[1], "| یکی هستند؟", scores[0] == scores[1])
دقت با sparse: 0.8673 | دقت با dense: 0.8673 | یکی هستند؟ True

مدل هر دو را قبول می‌کند و جوابِ یکسان می‌دهد. یعنی آن .toarray() بیست‌ونه برابر حافظه گرفت و صفر فایده داشت.

این پرتکرارترین کپیِ بی‌جا در کارِ متنی است، و روی همین پیکرهٔ کوچک فقط سه‌ونیم مگابایت خرج می‌کند. روی یک پیکرهٔ صدهزارتایی با واژگانِ پنجاه‌هزارکلمه‌ای، همان یک خط چهل گیگابایت می‌خواهد و runtimeِ Colab را می‌کشد.

چک کن: اگر عددِ «خانه‌های ناصفر» پیشِ تو خیلی بزرگ‌تر بود، احتمالاً normalise را جا انداخته‌ای و ی و ک عربی واژگان را باد کرده‌اند. نسبتِ ناصفرها باید حدودِ دو درصد باشد؛ هرچه واژگان بزرگ‌تر، این نسبت کوچک‌تر و سودِ sparse بیشتر.

۴. cache: نرخِ اصابت سقف را تعیین می‌کند#

lru_cache جوابِ هر ورودی را نگه می‌دارد و دفعهٔ بعد به‌جای محاسبه، برش می‌دارد. پیکرهٔ ما تیکتِ تکراری دارد، پس باید سود بدهد. چقدر؟

import functools

all_texts = [r["text"] for r in big]
unique_texts = sorted(set(all_texts))

primed = functools.lru_cache(maxsize=None)(normalise)
for t in all_texts:
    primed(t)
info = primed.cache_info()
print("فراخوانی‌ها:", len(all_texts), "| یکتا:", len(unique_texts))
print("اصابت (hit):", info.hits, "| خطا (miss):", info.misses)
print(f"نرخِ اصابت  : {info.hits / len(all_texts):.2%}")
print(f"سقفِ نظریِ بهبود با این نرخ: {1 / (1 - info.hits / len(all_texts)):.2f} برابر")
فراخوانی‌ها: 782 | یکتا: 539
اصابت (hit): 243 | خطا (miss): 539
نرخِ اصابت  : 31.07%
سقفِ نظریِ بهبود با این نرخ: 1.45 برابر

قبل از هر اندازه‌گیریِ زمان، سقف را می‌دانیم: ۱٫۴۵ برابر.

حساب ساده است: اگر ۳۱ درصدِ فراخوانی‌ها رایگان شوند، ۶۹ درصدِ کار می‌ماند و ۱ ÷ ۰٫۶۹ می‌شود ۱٫۴۵. حتی اگر cache کاملاً رایگان باشد، از این بالاتر نمی‌رود. حالا واقعیت را ببینیم — روی یک تابعِ ارزان و یک تابعِ گران:

def heavy(text):                       # یک تابعِ گران: همان کار، دویست بار
    out = normalise(text)
    for _ in range(200):
        out = normalise(out)
    return out


def cold_cache_run(fn, texts):
    cached = functools.lru_cache(maxsize=None)(fn)
    start = time.perf_counter()
    for t in texts:
        cached(t)
    return time.perf_counter() - start


def plain_run(fn, texts):
    start = time.perf_counter()
    for t in texts:
        fn(t)
    return time.perf_counter() - start


for name, fn in [("normalise (ارزان)", normalise), ("heavy (گران)", heavy)]:
    plain = min(plain_run(fn, all_texts) for _ in range(3))
    cached = min(cold_cache_run(fn, all_texts) for _ in range(3))
    print(f"  {name:<20} بدونِ cache {plain * 1000:>8.1f} ms | با cache {cached * 1000:>8.1f} ms"
          f" | {plain / cached:>5.2f} برابر")
  normalise (ارزان)    بدونِ cache      3.5 ms | با cache      2.6 ms |  1.36 برابر
  heavy (گران)         بدونِ cache    673.5 ms | با cache    457.9 ms |  1.47 برابر

هر دو تقریباً روی همان سقفِ ۱٫۴۵ نشستند — و این تصادفی نیست: نرخِ اصابت یکی است، پس سقف یکی است.

و آن ۱٫۴۷ که یک صدم از سقف بالاتر است، سقف را نشکسته. سقف یک حسابِ دقیق روی شمارشِ اصابت‌هاست و ۱٫۴۷ یک اندازه‌گیریِ زمان با نوسانِ خودش. هر وقت یک اندازه‌گیری کمی از حدِ نظری‌اش گذشت، اول به نوسانِ اندازه‌گیری شک کن، نه به حساب.

به cold_cache_run دقت کن: هر بار cacheِ تازه می‌سازد. اگر این کار را نکنیم و همان cacheِ گرم‌شده را دوباره بسنجیم، عددِ صد برابری می‌گیریم که معنایی ندارد — چون در کارِ واقعی هر بار runtime تازه بالا می‌آید. این یکی از رایج‌ترین راه‌های دروغ گفتن با بنچمارک است.

۵. cacheی که ضرر داد#

حالا همان تابعِ گران، روی ورودیِ بدونِ تکرار:

fresh = functools.lru_cache(maxsize=None)(heavy)
for t in unique_texts:
    fresh(t)
print("روی ورودیِ کاملاً یکتا → اصابت:", fresh.cache_info().hits,
      "| خطا:", fresh.cache_info().misses)

plain = min(plain_run(heavy, unique_texts) for _ in range(3))
cached = min(cold_cache_run(heavy, unique_texts) for _ in range(3))
print(f"بدونِ cache {plain * 1000:.1f} ms | با cache {cached * 1000:.1f} ms | {plain / cached:.2f} برابر")
روی ورودیِ کاملاً یکتا → اصابت: 0 | خطا: 539
بدونِ cache 466.9 ms | با cache 484.9 ms | 0.96 برابر

صفر اصابت، و ۰٫۹۶ برابر — یعنی کندتر.

cache مجانی نیست: برای هر فراخوانی باید کلید بسازد، hash بگیرد، در جدول بگردد، و بعد جوابِ تازه را ذخیره کند. وقتی هیچ‌وقت اصابتی در کار نباشد، این‌ها خالص هزینه‌اند — به‌علاوهٔ حافظه‌ای که هر ۵۳۹ ورودی را نگه می‌دارد و هرگز پس نمی‌دهد.

📏 اندازه بگیر: با چه چیزی مقایسه شد؟ با همان تابع بدونِ cache، روی همان ورودی‌ها. روی کدام داده؟ یک بار کلِ ۷۸۲ فراخوانی (با ۳۱ درصد تکرار) و یک بار فقط ۵۳۹ ورودیِ یکتا. با چند seed؟ بی‌ربط است — ولی جایش هر اندازه‌گیری سه بار تکرار شده و کمینه گزارش شده، و هر بار با cacheِ سرد. بدونِ آن قیدِ آخر، این جدول کاملاً بی‌معنا می‌شد.

۶. maxsize: حافظه در برابرِ اصابت#

bounded = functools.lru_cache(maxsize=64)(normalise)
for t in all_texts:
    bounded(t)
print("با maxsize=64  → اصابت:", bounded.cache_info().hits, "| کلیدهای نگه‌داشته:", bounded.cache_info().currsize)
print("با maxsize=None → اصابت:", primed.cache_info().hits, "| کلیدهای نگه‌داشته:", primed.cache_info().currsize)
با maxsize=64  → اصابت: 47 | کلیدهای نگه‌داشته: 64
با maxsize=None → اصابت: 243 | کلیدهای نگه‌داشته: 539

سقفِ ۶۴تایی حافظه را هشت برابر کم کرد و اصابت را پنج برابر.

و این دقیقاً همان معامله‌ای است که باید آگاهانه انجام بدهی. maxsize=None یعنی «هرگز چیزی را دور نریز» — که در یک برنامهٔ کوتاه بی‌خطر است و در سرویسی که ماه‌ها بالاست، یک نشتِ حافظهٔ کامل با کلیدهایی که هرگز دوباره نمی‌آیند.

🔧 اگر کار نکرد: اگر TypeError: unhashable type: 'dict' گرفتی، lru_cache را روی تابعی گذاشته‌ای که dict یا list می‌گیرد. cache باید ورودی را به‌عنوانِ کلید نگه دارد و کلید باید hashable باشد. راهش این است که cache را روی لایهٔ داخلی‌تری بگذاری که فقط رشته یا عدد می‌گیرد — دقیقاً مثلِ همین normalise.

🤖 از دستیارت بپرس: «چرا numpy روی آرایهٔ پیوسته این‌قدر سریع‌تر از حلقهٔ پایتونی است؟» جوابِ کامل شاملِ سه چیز است: نبودِ سربارِ مفسر، دادهٔ پیوسته در حافظه، و دستورهایی که چند عدد را هم‌زمان حساب می‌کنند. بعد خودت این را امتحان کن: nearest_vectorised را روی M با هزار سطر بزن و ببین حافظه چه می‌کند. راهنمایی: M @ M.T یک ماتریسِ n×n می‌سازد. برداری‌سازی زمان را می‌خرد و گاهی با حافظه پول می‌دهد.

واژه‌های تازهٔ این فصل#

کلمه تلفظ به حروف فارسی یعنی چه
vectorisation وکتورایزیشن انجامِ یک عمل روی کلِ آرایه به‌جای حلقه
sparse matrix اسپارس ماتریکس ماتریسی که فقط خانه‌های ناصفرش ذخیره می‌شود
shallow copy شالو کپی کپیِ ظرف، بدونِ کپیِ محتوا
lru_cache ال‌آر‌یو کش نگه‌داشتنِ جوابِ فراخوانی‌های قبلی
hit rate هیت ریت سهمِ فراخوانی‌هایی که در cache پیدا می‌شوند
maxsize مکس‌سایز سقفِ تعدادِ کلیدهایی که cache نگه می‌دارد

تمرین‌ها

اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.

در فصل بعد#

تا اینجا همه‌چیز یک کار در یک زمان بود. فصلِ بعد سراغِ هم‌زمانی می‌رود — نخ، فرآیند، و async — و همان‌جا با تلخ‌ترین عددِ این ترم روبه‌رو می‌شویم: چهار نخ روی کارِ محاسباتی، دقیقاً همان‌قدر طول می‌کشد که یک نخ. دلیلش اسم دارد و GIL نامیده می‌شود.

به آخر این فصل رسیدی!

اگر ساختی و جواب داد، این دکمه مال توست.