CONTENTS

പൈത്തണിൽ അത്രയും സാധാരണമല്ലാത്ത പ്രകടന ഒപ്റ്റിമൈസേഷൻ

എന്റെ മുമ്പത്തെ പോസ്റ്റ് (സത്യത്തിൽ ഈ സൈറ്റിന്റെ തീം പരീക്ഷിക്കാനായി ഉണ്ടാക്കിയത്), വിപരീത വർഗ്ഗങ്ങളുടെ തുകയുടെ പദങ്ങൾ കണക്കാക്കിയ ചില കോഡ് ഖണ്ഡികകൾ നൽകി. ഞാൻ ആ കോഡ് എന്റെ 4 പ്രിയപ്പെട്ട ഭാഷകളായ—പൈത്തൺ, സി, റസ്റ്റ്, ഹാസ്കൽ എന്നിവയിൽ എഴുതി, പക്ഷേ പൈത്തൺ കോഡ് റൺ ചെയ്തപ്പോൾ, അത് നാണംകെടുത്ത അളവിൽ മന്ദഗതിയിലായിരുന്നു. സീക്വൻഷ്യൽ റസ്റ്റിന് എടുത്ത മില്ലിസെക്കൻഡുമായി താരതമ്യപ്പെടുത്തുമ്പോൾ, പൈത്തൺ 70 സെക്കൻഡ് എടുത്തു! അതിനാൽ, ഈ പോസ്റ്റിൽ, പൈത്തണിന് കുറച്ച് കൂടി യുക്തിസഹമായ സംഖ്യകൾ ലഭിക്കുന്നതിന് ശ്രമിക്കാൻ പോകുന്നു.

യഥാർത്ഥ പരിഹാരം

def basel(N: int) -> float:
    return sum(x**(-2) for x in range(1,N))

ഇതാണ് ഈ ജോലി ചെയ്യാനുള്ള ഏറ്റവും പൈത്തോണിക് മാർഗം. എളുപ്പത്തിൽ വായിക്കാവുന്ന, ബിൽറ്റ്-ഇൻ ജനറേറ്ററുകൾ ഉപയോഗിക്കുന്ന ഒരു വരി കോഡ്. ഇനി സമയം നോക്കാം, എന്നതിന് പകരം ഉപയോഗിച്ച് (കാത്തിരിക്കാൻ ഞാൻ ആഗ്രഹിക്കാത്തതിനാൽ):

import time

# ഇൻപുട്ട് ഫങ്ഷൻ ടൈം ചെയ്യുക
def time_function(func):
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        execution_time = end_time - start_time
        print(f"Function '{func.__name__}' executed in {execution_time:.6f} seconds.")
        return result
    return wrapper
>>> f = time_function(basel)
>>> f(100000000) # 10^8
Function 'basel' executed in 6.641589 seconds.
1.644934057834575

ഇനി അത് വീണ്ടും എഴുതാം, പക്ഷേ കുറച്ച് പൈത്തോണിക് അല്ലാതെ, ഒരു for ലൂപ്പ് ഉപയോഗിച്ച്:

def basel_less_pythonic(N: int) -> float:
    s = 0.0
    for x in range(1, N):
        s += x**(-2)
    return s
>>> f(100000000) # 10^8
Function 'basel_less_pythonic' executed in 5.908466 seconds.
1.644934057834575

രസകരമായ കാര്യം. ഇത് എഴുതാനുള്ള കുറച്ച് അപരിചിതമായ മാർഗം യഥാർത്ഥത്തിൽ പ്രകടനം മെച്ചപ്പെടുത്തി. എന്തുകൊണ്ട്? dis, പൈത്തൺ ഡിസാസംബ്ലി മൊഡ്യൂൾ ഉപയോഗിച്ച് നമുക്ക് അന്വേഷിക്കാം.

യഥാർത്ഥ പതിപ്പ് ഇതാ. ഞാൻ ചില സഹായകരമായ കമന്റുകൾ ചേർത്തിട്ടുണ്ട്:

# വേരിയബിളുകൾ ലോഡ് ചെയ്യുക
00 LOAD_GLOBAL              0 (sum)
02 LOAD_CONST               1 (<code object <genexpr> at 0x104f42e40>)
04 LOAD_CONST               2 ('basel.<locals>.<genexpr>')

# ഫങ്ഷൻ സൃഷ്ടിക്കുക
06 MAKE_FUNCTION            0

# `range` ഒബ്ജക്റ്റ് സൃഷ്ടിക്കുക
08 LOAD_GLOBAL              1 (range)
10 LOAD_CONST               3 (1)
12 LOAD_FAST                0 (N)
14 CALL_FUNCTION            2

# ഇറ്ററേറ്ററായി പരിവർത്തനം ചെയ്യുക
16 GET_ITER

# ജനറേറ്റർ വിളിക്കുക
18 CALL_FUNCTION            1

# സം ഫങ്ഷൻ വിളിക്കുക
20 CALL_FUNCTION            1

# റിട്ടേൺ
22 RETURN_VALUE

f <code object <genexpr> at 0x104f42e40>:
# അടിസ്ഥാനപരമായി yield ഉള്ള ഒരു `for` ലൂപ്പ്
00 GEN_START                0
02 LOAD_FAST                0 (.0)
04 FOR_ITER                 7 (to 20)
06 STORE_FAST               1 (x)
08 LOAD_FAST                1 (x)
10 LOAD_CONST               0 (-2)
12 BINARY_POWER
14 YIELD_VALUE
16 POP_TOP
18 JUMP_ABSOLUTE            2 (to 4)
20 LOAD_CONST               1 (None)
22 RETURN_VALUE

for ലൂപ്പ് പതിപ്പ് ഇതാ:

# വേരിയബിളുകൾ ലോഡ് ചെയ്യുക
00 LOAD_CONST               1 (0.0)
02 STORE_FAST               1 (s)

# `range` ഒബ്ജക്റ്റ് സൃഷ്ടിക്കുക
04 LOAD_GLOBAL              0 (range)
06 LOAD_CONST               2 (1)
08 LOAD_FAST                0 (N)
10 CALL_FUNCTION            2
12 GET_ITER

# `for` ലൂപ്പ് ഡിക്ലെയർ ചെയ്യുക
14 FOR_ITER                 8 (to 32)
16 STORE_FAST               2 (x)

# പവർ, കൂട്ടുക, സ്റ്റോർ ചെയ്യുക
18 LOAD_FAST                1 (s)
20 LOAD_FAST                2 (x)
22 LOAD_CONST               3 (-2)
24 BINARY_POWER
26 INPLACE_ADD
28 STORE_FAST               1 (s)

# `for` ലൂപ്പിന്റെ മുകളിലേക്ക് പോകുക
30 JUMP_ABSOLUTE            7 (to 14)

# `s` റിട്ടേൺ ചെയ്യുക
32 LOAD_FAST                1 (s)
34 RETURN_VALUE

പ്രത്യേക സാഹചര്യത്തിൽ, ജനറേറ്റർ ഉപയോഗിക്കുന്നത് പ്രോഗ്രാം മന്ദഗതിയിലാക്കി. നമുക്ക് കാണാനാകുന്നതുപോലെ, ജനറേറ്റർ ലൂപ്പ് കോഡ് രണ്ടാമത്തെ പതിപ്പിന് സമാനമാണ്. ആദ്യത്തേത് ജനറേറ്റർ ഒബ്ജക്റ്റുമായി കൈകാര്യം ചെയ്യുന്ന അധിക ജോലി മാത്രമാണ് ചെയ്യുന്നത്, അതിന് (മന്ദഗതിയുള്ള) CALL_FUNCTION ഡയറക്ടീവുകൾ ആവശ്യമാണ്. പ്രായോഗികമായി, ജനറേറ്ററുകൾ സാധാരണയായി അവയുടെ മടിയൻ സ്വഭാവം കാരണം വേഗതയുള്ളതാണ് എന്നത് ശ്രദ്ധിക്കുക. ഈ സാഹചര്യത്തിൽ മാത്രം, അധിക ബ്ലോട്ട് അതിന്റെ മൂല്യം നൽകുന്നില്ല.

എന്തായാലും, രണ്ട് പതിപ്പുകൾക്കിടയിലുള്ള പ്രകടന വ്യത്യാസം ( s) പൈത്തണും സി/റസ്റ്റും തമ്മിലുള്ള മൊത്തത്തിലുള്ള വ്യത്യാസത്തോട് താരതമ്യം ചെയ്യുമ്പോൾ നിസ്സാരമാണ്. വേഗതയേറിയ പതിപ്പ് ഉപയോഗിച്ച് പോലും, റസ്റ്റ് കോഡ് പൈത്തണിനേക്കാൾ മടങ്ങ് വേഗമുള്ളതാണ്.

എന്തുകൊണ്ട്? പ്രധാനമായും കാരണം പൈത്തൺ ഒരു ശിഥിലമായ ടൈപ്പുള്ള, ഇന്റർപ്രെറ്റ് ചെയ്യപ്പെടുന്ന ഭാഷയാണ്. ഇതിനർത്ഥം പൈത്തൺ ഇന്റർപ്രെറ്റർ (സിപൈത്തൺ), പൈത്തൺ കോഡ് നിങ്ങളുടെ കമ്പ്യൂട്ടർ എളുപ്പത്തിൽ എക്സിക്യൂട്ട് ചെയ്യാൻ കഴിയുന്ന ഒന്നായി തൽക്ഷണം പരിവർത്തനം ചെയ്യണം. പൈത്തൺ കോഡ് പൈത്തൺ ബൈറ്റ്കോഡ് എന്ന് വിളിക്കപ്പെടുന്ന അസംബ്ലിയുടെ ഒരു സാമാന്യവൽക്കരിച്ച രൂപത്തിലേക്ക് വേഗത്തിൽ കംപൈൽ ചെയ്യുന്നതിലൂടെയാണ് ഇത് ചെയ്യുന്നത്, അത് നമ്മൾ ഇപ്പോൾ നോക്കി.

ഇത് തുടർന്ന് ഇന്റർപ്രെറ്റർ എക്സിക്യൂട്ട് ചെയ്യുന്നു. ഈ കംപൈലേഷൻ ഘട്ടം പൈത്തൺ അനുഭവിക്കുന്ന ആദ്യത്തെ പ്രകടന പ്രശ്നമാണ്. റസ്റ്റ് പോലുള്ള ഭാഷകൾക്ക് ഒരു പ്രോഗ്രാം ഒരിക്കൽ മാത്രം കംപൈൽ ചെയ്യേണ്ടതുണ്ട്, അതിനാൽ ആ സമയം ഒരു പ്രോഗ്രാമിന്റെ റൺടൈമിൽ ഉൾപ്പെടുത്തിയിട്ടില്ല. എന്നാൽ പ്രകടനത്തിന് ഏറ്റവും വലിയ പ്രശ്നം നേറ്റീവ് ഒപ്റ്റിമൈസ് ചെയ്തതിന് പകരം മോശം നിലവാരമുള്ള, ആർക്കിടെക്ചർ അഗ്നോസ്റ്റിക് ബൈറ്റ്കോഡ് ഉള്ളതാണ്. ഇത് ഇന്റർപ്രെറ്റ് ചെയ്യപ്പെടുന്ന ഭാഷകളുടെ സ്വഭാവത്തിന്റെ ഒരു ഭാഗമാണ്, കാരണം അവയ്ക്ക് ഉയർന്ന നിലവാരമുള്ള കംപൈലേഷനിൽ വളരെയധികം സമയം ചെലവഴിക്കാൻ കഴിയില്ല.

അപ്പോൾ, നിങ്ങൾക്ക് എങ്ങനെ വേഗതയേറിയ പൈത്തൺ എഴുതാം? ശരി, നിങ്ങൾക്ക് കഴിയില്ല. പക്ഷേ, നിങ്ങൾക്ക് വിളിക്കാം വേഗതയേറിയ സി കോഡിലേക്ക്, നമ്പൈ പോലുള്ള കനത്ത ഒപ്റ്റിമൈസ് ചെയ്ത ലൈബ്രറികളിലൂടെ. ഈ ലൈബ്രറികളിൽ മുൻകൂട്ടി കംപൈൽ ചെയ്ത, വെക്റ്ററൈസ് ചെയ്ത സി ഫങ്ഷനുകൾ അടങ്ങിയിരിക്കുന്നു, അവ നിങ്ങളെ മുഴുവൻ പൈത്തൺ ഇന്റർപ്രെറ്റർ ഒഴിവാക്കാൻ അനുവദിക്കുന്നു.

നമുക്ക് നമ്പി ഉപയോഗിക്കാം

import numpy as np

def basel_np(N: int) -> float:
    # [1, 1, ..., 1]
    ones = np.ones(N - 1)
    # [1, 2, ..., N]
    r = np.arange(1, N)
    # [1, 1/2, ..., 1/N]
    div = ones / r
    # [1, 1/4, ..., 1/N^2]
    inv_squares = np.square(div)
    # ~ pi^2/6
    return float(np.sum(inv_squares))
>>> f(100000000) # 10^8
Function 'basel_np' executed in 0.460317 seconds.
1.6449340568482196

വാവ്, അത് മടങ്ങ് വേഗത്തിൽ പ്രവർത്തിച്ചു! ഈ സാഹചര്യത്തിൽ ബൈറ്റ്കോഡ് നോക്കുന്നത് വളരെയധികം പറയില്ല, കാരണം അത് നമ്പിയിലേക്കുള്ള കുറച്ച് CALL_FUNCTION കോളുകൾ മാത്രമായിരിക്കും, യഥാർത്ഥ പ്രവൃത്തി നടക്കുന്നത് അവിടെയാണ്. ഏത് ലൈനാണ് ഏറ്റവും കൂടുതൽ സമയം എടുക്കുന്നതെന്ന് നോക്കാം:

def basel_np(N: int) -> tuple[float, list[float]]:
    times = []

    # ഒരൊറ്റ ഘട്ടം സമയം നോക്കുക
    start = time.perf_counter()
    ones = np.ones(N - 1)
    end = time.perf_counter()
    step_time = end - start
    times.append(step_time)

    # ബാക്കിയുള്ള സമയം നോക്കുന്ന കോഡ് ഒഴിവാക്കി
    r = np.arange(1, N)
    div = ones / r
    square = np.square(div)
    ret = np.sum(square)

    return ret, times
പ്രവർത്തനം പ്രവർത്തനത്തിനുള്ള സമയം (മില്ലിസെക്കൻഡ്) ആകെ സമയം (മില്ലിസെക്കൻഡ്)
ones സൃഷ്ടിക്കൽ 97 97
range സൃഷ്ടിക്കൽ 79 176
range വർഗ്ഗീകരണം 98 274
ones/squares ഹരണം 112 387
അന്തിമ തുക 58 444

ഈ സംഖ്യകളെക്കുറിച്ച് ഒരു നിമിഷം ചിന്തിക്കാം. ones/range സൃഷ്ടിക്കൽ ഘട്ടങ്ങളിൽ കനത്ത കമ്പ്യൂട്ടേഷണൽ ജോലിയൊന്നുമില്ല. എന്നിട്ടും, വർഗ്ഗീകരണം, ഹരണം തുടങ്ങിയ “കഠിന” ഘട്ടങ്ങൾക്ക് ഏതാണ്ട് തുല്യമായ സമയമാണ് അവ എടുക്കുന്നത്. അത്ഭുതകരമെന്നു പറയട്ടെ, അന്തിമ അറേയിലെ തുക കണ്ടെത്തുന്നതാണ് ഏറ്റവും വേഗത്തിലുള്ള ഘട്ടം! ഇത് സൂചിപ്പിക്കുന്നത് ഇവിടെയുള്ള തടസ്സം സിപിയു അല്ല, മെമ്മറി ആക്സസ് ആണ് എന്നാണ്. മുതൽ വരെ മാറ്റിനോക്കുമ്പോൾ പ്രകടനം എങ്ങനെ മാറുന്നു എന്ന് നോക്കാം.

NumPy സമീപനത്തിന്റെ വിവിധ ഇൻപുട്ട് വലുപ്പങ്ങളിലുള്ള പ്രവർത്തന സമയ വിഭജനം
NumPy സമീപനത്തിന്റെ വിവിധ ഇൻപുട്ട് വലുപ്പങ്ങളിലുള്ള പ്രവർത്തന സമയ വിഭജനം

ഈ പ്ലോട്ടിൽ, ഏറ്റവും മുകളിലെ വരിയുടെ അറ്റം ആകെ എടുത്ത സമയത്തെ പ്രതിനിധീകരിക്കുന്നു, തുടർച്ചയായ വരികൾക്കിടയിലുള്ള ഇടം ഓരോ ഘട്ടത്തിനും എടുത്ത സമയത്തെ പ്രതിനിധീകരിക്കുന്നു.

നമുക്ക് കാണാം:

  1. അൽഗോരിതം ആണെങ്കിലും, ആകെ സമയം രേഖീയമല്ലാതെ വർദ്ധിക്കുന്നു.

  2. divide ഘട്ടമാണ് ഏറ്റവും കൂടുതൽ സമയം എടുക്കുന്നത്, , എന്നിവയിൽ കൂർത്ത വർദ്ധനവുമായി.

ഇത് അർത്ഥവത്താണ്, കാരണം മറ്റ് പ്രവർത്തനങ്ങളിൽ നിന്ന് വ്യത്യസ്തമായി, np.divide രണ്ട് വലിയ അറേകൾ ഒരേസമയം ആക്സസ് ചെയ്യേണ്ടതുണ്ട്, ഫലം ഒരു പുതിയ അറേയിലേക്ക് എഴുതേണ്ടതുണ്ട്. ഇതിനർത്ഥം ധാരാളം പ്രധാന മെമ്മറി ആക്സസുകളും, സാധ്യമെങ്കിൽ SSD-യിൽ നിന്നുള്ള വായനകളും ആവശ്യമാണ്, അത് അങ്ങേയറ്റം മന്ദഗതിയിലാണ്.

ബ്രോഡ്കാസ്റ്റിംഗ്

ഇത്തരം പ്രശ്നങ്ങൾക്കായി നമ്പൈ ഒരു ഒപ്റ്റിമൈസേഷൻ നിർമ്മിച്ചിട്ടുണ്ട്, അതിനെ ബ്രോഡ്കാസ്റ്റിംഗ് എന്ന് വിളിക്കുന്നു. ഇത് വെർച്വലായി ചെറിയ വെക്ടറുകളെ വലിയ വലിപ്പത്തിലേക്ക് “പ്രൊജക്റ്റ്” ചെയ്ത് വെക്ടർ-വെക്ടർ ഓപ്പറേഷനുകൾ നടത്താൻ സാധിക്കുന്നു.

def basel_np_broadcast(N) -> float:
    ones = 1
    r = np.arange(1, N)
    div = ones / r
    square = np.square(div)
    # [1, 1, ..., 1] / [1, 4, ..., N^2] എന്നത് പോലെ
    return float(np.sum(square))

ഇത് വിലപ്പെട്ട കാഷെ സ്ഥലം ലാഭിക്കാൻ നമ്മെ സഹായിക്കുന്നു. മുമ്പത്തെ അതേ മെട്രിക്സ് ഇപ്പോൾ പ്രവർത്തിപ്പിക്കാം. ഉപയോഗിച്ച്:

ഓപ്പറേഷൻ ഓപ്പറേഷന് എടുത്ത സമയം (ms) ആകെ സമയം (ms)
ones സൃഷ്ടിക്കൽ 0.00 0
range സൃഷ്ടിക്കൽ 68.56 70.48
range വർഗ്ഗീകരണം 105.14 180.74
ones/squares ഹരണം 133.08 271.30
അന്തിമ തുക 71.08 310.41
ബ്രോഡ്കാസ്റ്റ് ചെയ്ത നമ്പൈ രീതിയുടെ റൺടൈം വിശകലനം
ബ്രോഡ്കാസ്റ്റ് ചെയ്ത നമ്പൈ രീതിയുടെ റൺടൈം വിശകലനം

ഇനി മുതൽ, ബ്രോഡ്കാസ്റ്റ് ചെയ്ത പതിപ്പിനെ “നമ്പൈ പരിഹാരം” എന്ന് വിളിക്കാം.

കാര്യമായ മെച്ചപ്പെടുത്തലായിരുന്നിട്ടും, ആ ലെറ്റൻസി സ്പൈക്കുകൾ ഇപ്പോഴും കാണാം. അത് പരിഹരിക്കാൻ ശ്രമിക്കാം.

മെമ്മറിയെക്കുറിച്ച് ചിന്തിക്കുക

ഫങ്ഷന്റെ മെമ്മറി ഉപയോഗം മെച്ചപ്പെടുത്താൻ, നമുക്ക് ബ്ലോക്ക്-ബൈ-ബ്ലോക്ക് പ്രവർത്തിക്കാം, ചെറിയ ചങ്കുകളിൽ ഒരേ ഓപ്പറേഷനുകൾ ചെയ്ത്, ഒടുവിൽ എല്ലാം കൂട്ടിച്ചേർക്കുക. ഇത് ഒരു സമയം ഒരു ചങ്ക് മാത്രം മെമ്മറിയിൽ സൂക്ഷിക്കാൻ സഹായിക്കും, കൂടാതെ കാഷെ ഉപയോഗം മെച്ചപ്പെടുത്തുമ്പോൾ തന്നെ Numpy യുടെ വെക്റ്ററൈസ്ഡ് കോഡിന്റെ പ്രയോജനം നിലനിർത്താനും സാധിക്കും.

കളുടെ ഒരു ശ്രേണി എടുക്കാൻ ഫങ്ഷൻ പരിഷ്കരിക്കാം:

# [N1, N2], inclusive
def basel_np_range(N1: int, N2: int) -> float:
    # ടൈമിംഗ് കോഡ് ഒഴിവാക്കി
    ones = 1
    r = np.arange(N1, N2 + 1)
    div = ones / r
    squares = np.square(div)
    return float(np.sum(squares))

എല്ലാ ചങ്കുകളും കൂട്ടിച്ചേർക്കാൻ ഒരു ഫങ്ഷൻ എഴുതാം.

def basel_chunks(N: int, chunk_size: int) -> float:
    # ടൈമിംഗ് കോഡ് ഒഴിവാക്കി
    s = 0.0
    num_chunks = N // chunk_size
    for i in range(num_chunks):
        s += basel_np_range(i * chunk_size + 1, (i + 1) * chunk_size)
    return s

ഉപയോഗിച്ച്:

Function 'basel_chunks' executed in 0.108557 seconds.
1.6449340568482258

കൊള്ളാം! ഇത് Numpy പരിഹാരത്തേക്കാൾ മടങ്ങ് വേഗത്തിൽ പ്രവർത്തിക്കുന്നു.

കൂടാതെ, IEEE 754 ഫ്ലോട്ടുകളുടെ സ്വഭാവം കാരണം ഉത്തരം യഥാർത്ഥത്തിൽ കുറച്ച് കൂടി കൃത്യമായിരിക്കും. മുമ്പ് ചെയ്തതുപോലെ പ്രകടനം എങ്ങനെ സ്കെയിൽ ചെയ്യുന്നുവെന്ന് നോക്കാം, ചങ്ക് വലുപ്പം സ്ഥിരമായി നിലനിർത്തിക്കൊണ്ട്.

വ്യത്യസ്ത ഇൻപുട്ട് വലുപ്പങ്ങൾക്കുള്ള ചങ്ക് ചെയ്ത NumPy റൺടൈം
വ്യത്യസ്ത ഇൻപുട്ട് വലുപ്പങ്ങൾക്കുള്ള ചങ്ക് ചെയ്ത NumPy റൺടൈം

ഒന്നാമതായി, y-അക്ഷം നോക്കുക. ഇപ്പോൾ നമ്മൾ മിനിറ്റുകളുടെ അളവിലല്ല, സെക്കൻഡുകളുടെ അളവിൽ പ്രവർത്തിക്കുന്നു. റൺടൈം രേഖീയമായി വർദ്ധിക്കുന്നതും കാണാം, അൽഗോരിതത്തിന്റെ സമയ സങ്കീർണ്ണതയുമായി യോജിക്കുന്നു. chunk_size 20000 ആയാൽ എല്ലാ വിവരങ്ങളും കാഷെയിൽ യോജിപ്പിക്കാൻ കഴിയുമെന്ന് സിദ്ധാന്തിക്കാം, അതിനാൽ കാഷെയ്ക്ക് പുറത്തുള്ള ആക്സസ്സിന് റൺടൈം സ്പൈക്കുകളില്ല.

chunk_size ശ്രേണിയിൽ മാറ്റി നോക്കാം, ആയി സ്ഥിരമായി നിലനിർത്തിക്കൊണ്ട്.

N = 10^9 ന് ചങ്ക് വലുപ്പം മാറുമ്പോഴുള്ള റൺടൈം
N = 10^9 ന് ചങ്ക് വലുപ്പം മാറുമ്പോഴുള്ള റൺടൈം

ചിത്രത്തിൽ നിന്ന്, chunk_size വരെ പ്രകടന ലാഭം കാണാം, അതിനുശേഷം കാഷെ മിസുകൾ മൂലമുണ്ടാകുന്ന ലെറ്റൻസി സ്പൈക്കുകൾ കാണാം.

ചില നാപ്കിൻ കണക്കുകൾ

കാഷെ ഹൈരാർക്കി

ഓരോ float64 ഉം 64 ബിറ്റുകൾ, അതായത് 8 ബൈറ്റുകൾ ഉപയോഗിക്കുന്നു. നമ്മൾ float64 മൂല്യങ്ങളുള്ള 3 അറേകൾ ഉപയോഗിക്കുന്നു, അതിനാൽ ഒരു കോളിനുള്ള മെമ്മറി ഉപയോഗം MB ആണ്.

എന്റെ M1 Pro-യുടെ സ്പെസിഫിക്കേഷനുകൾ ഇവയാണ്:

മെമ്മറി തരം വലുപ്പം
L1 128KB (ഡാറ്റാ കാഷെ, പെർ കോർ)
L2 24MB (6 പെർഫോമൻസ് കോറുകൾക്കിടയിൽ പങ്കുവെച്ചത്)
L3 24MB
പ്രധാന 16GB

ഇത് സൂചിപ്പിക്കുന്നത് ഫങ്ഷന് ലഭ്യമായ പരമാവധി കാഷെ സ്ഥലം MB ആണ്, അതിൽ കൂടുതൽ പോയാൽ നമുക്ക് കാഷെക്കായി കാത്തിരിക്കേണ്ടി വരും.

മൾട്ടിപ്രോസസ്സിംഗ്

ഇനി, കമ്പ്യൂട്ടർ അറേയുടെ കൂടുതൽ ഭാഗം കാഷെയിൽ ഫിറ്റ് ചെയ്യാൻ ശ്രമിക്കാം. ഒരു സാധ്യത, ഫങ്ഷൻ എല്ലാ കോറുകളിലും റൺ ചെയ്യാൻ ശ്രമിക്കുക എന്നതാണ്, അങ്ങനെ നമുക്ക് കൂടുതൽ ഞങ്ങളുടെ ഫങ്ഷനായി ഉപയോഗിക്കാം, അതുകൊണ്ട് അത് പരീക്ഷിക്കാം.

ആദ്യം, basel_chunks ഒരു കളുടെ ശ്രേണി ഇൻപുട്ടായി എടുക്കാൻ മാറ്റാം.

# (N1, N2]
def basel_chunks_range(N1: int, N2: int, chunk_size: int):
    # ടൈമിംഗ് കോഡ് ഒഴിവാക്കി
    s = 0.0
    num_chunks = (N2 - N1) // chunk_size
    for i in range(num_chunks):
        s += basel_np_range(N1 + i * chunk_size + 1, N1 + (i + 1) * chunk_size)
    return s

ഇനി യഥാർത്ഥ മൾട്ടിപ്രോസസ്സിംഗിന്:

from multiprocessing import Pool

def basel_multicore(N: int, chunk_size: int):
    # എന്റെ മെഷീനിലെ 10 കോറുകൾ
    NUM_CORES = 10
    N_PER_CORE = N // NUM_CORES
    Ns = [
        (i * N_PER_CORE, (i + 1) * N_PER_CORE, chunk_size)
        for i in range(NUM_CORES)
    ]
    # 10 ബാച്ചുകൾ സമാന്തരമായി പ്രോസസ്സ് ചെയ്യുക
    with Pool(NUM_CORES) as p:
        result = p.starmap(basel_chunks_range, Ns)
    return sum(result)

അടിഭാഗത്ത്, പൈത്തണിന്റെ മൾട്ടിപ്രോസസ്സിംഗ് മൊഡ്യൂൾ പിക്കിൾ ചെയ്യുന്നു ഫങ്ഷൻ ഒബ്ജക്റ്റ്, കൂടാതെ 9 പുതിയ python3.10 ഇൻസ്റ്റൻസുകൾ സൃഷ്ടിക്കുന്നു, ഇവയെല്ലാം ഉം num_chunks = 50000 ഉം ഉപയോഗിച്ച് ഫങ്ഷൻ എക്സിക്യൂട്ട് ചെയ്യുന്നു.

പ്രകടനം താരതമ്യം ചെയ്യാം.

ഫങ്ഷൻ 'basel_multicore' 1.156558 സെക്കൻഡിൽ എക്സിക്യൂട്ട് ചെയ്തു.
1.6449340658482263
ഫങ്ഷൻ 'basel_chunks' 1.027350 സെക്കൻഡിൽ എക്സിക്യൂട്ട് ചെയ്തു.
1.6449340658482263

അയ്യോ, ഇത് മോശമായി പ്രവർത്തിച്ചു. ഇതിന് കാരണം മൾട്ടിപ്രോസസ്സിംഗ് ചെയ്യുന്ന പ്രാരംഭ പ്രവൃത്തി ചെലവേറിയതാണ്, അതിനാൽ ഇത് ഗുണം ചെയ്യും ഫങ്ഷൻ പൂർത്തിയാക്കാൻ താരതമ്യേന കൂടുതൽ സമയം എടുക്കുകയാണെങ്കിൽ മാത്രം.

ആയി വർദ്ധിപ്പിക്കുന്നു:

ഫങ്ഷൻ 'basel_multicore' 2.314828 സെക്കൻഡിൽ എക്സിക്യൂട്ട് ചെയ്തു.
1.6449340667482235
ഫങ്ഷൻ 'basel_chunks' 10.221904 സെക്കൻഡിൽ എക്സിക്യൂട്ട് ചെയ്തു.
1.6449340667482235

അതാ! ഇത് മടങ്ങ് വേഗത്തിലാണ്. പരീക്ഷിക്കാം

ഫങ്ഷൻ 'basel_multicore' 13.844876 സെക്കൻഡിൽ എക്സിക്യൂട്ട് ചെയ്തു.
multicore 1.6449340668379773
ഫങ്ഷൻ 'basel_chunks' 102.480372 സെക്കൻഡിൽ എക്സിക്യൂട്ട് ചെയ്തു.
chunks 1.6449340668379773

അത് മടങ്ങ് വേഗത്തിലാണ്! വർദ്ധിക്കുമ്പോൾ, അനുപാതം സിംഗിൾ-കോറിന്റെതും 10-കോറിന്റെതും 10:1 ആയി സമീപിക്കണം.

ചങ്ക് ചെയ്തതും മൾട്ടികോർ ഇംപ്ലിമെന്റേഷനുകളും തമ്മിലുള്ള റൺടൈം താരതമ്യം
ചങ്ക് ചെയ്തതും മൾട്ടികോർ ഇംപ്ലിമെന്റേഷനുകളും തമ്മിലുള്ള റൺടൈം താരതമ്യം

x-അക്ഷം ഒരു ലോഗ് സ്കെയിലിലാണ്, അതുകൊണ്ടാണ് രേഖീയമായി വർദ്ധിക്കുന്ന റൺടൈമുകൾ എക്സ്പോണൻഷ്യൽ ആയി കാണപ്പെടുന്നത്

മൾട്ടിപ്രോസസ്സിംഗ് ഉപയോഗിക്കുന്നതിന് സെക്കൻഡ് ഓവർഹെഡ് ഉണ്ടെന്ന് നമുക്ക് കാണാം, അതിനാൽ ചങ്ക് ചെയ്ത റൺടൈം ഉയർന്നതായിരിക്കുമ്പോൾ മാത്രമേ പ്രകടന ഗുണം ഉണ്ടാകൂ. ഇത് എന്നതിൽ സംഭവിക്കുന്നു. എന്നിരുന്നാലും, അതിനുശേഷം, ഒരു വലിയ വ്യത്യാസമുണ്ട്.

പരീക്ഷണത്തിലൂടെ (ഇവിടെ കാണിക്കാത്തത്) ഒരു chunk_size ഏകദേശം 50000 എന്നതും മൾട്ടിപ്രോസസ്സിംഗ് കോഡിന് ഒപ്റ്റിമൽ ആണെന്ന് ഞാൻ കണ്ടെത്തി, ഇത് ഓപ്പറേറ്റിംഗ് സിസ്റ്റം ഒരു കോറിനുള്ള L2 ഉപയോഗത്തിൽ ചില നിയന്ത്രണങ്ങൾ ഏർപ്പെടുത്തുന്നുവെന്ന് നമ്മോട് പറയുന്നു.

ഫലങ്ങളുടെ സംഗ്രഹം

പൈത്തൺ ഫങ്ഷൻ (സെ) (സെ) (സെ)
basel_multicore 1.04 1.17 2.33
basel_chunks 0.09 0.91 9.18
basel_np_broadcast 0.27 9.68 മെമ്മറി പോരാ (OOM)
basel_less_pythonic 5.99 59.25 592 (ഏകദേശം)
basel (സ്വാഭാവികം) 7.64 68.28 682 (ഏകദേശം)
basel_np 0.618 44.27 മെമ്മറി പോരാ (OOM)

കഴിഞ്ഞ പോസ്റ്റിലെ ഭാഷകളുമായി ഇത് എങ്ങനെ താരതമ്യം ചെയ്യപ്പെടുന്നു?

ഭാഷ (മില്ലിസെക്കൻഡ്)
റസ്റ്റ് (rc, –release, മൾട്ടികോർ w/ rayon) 112.65
പൈത്തൺ3.10 (basel_chunks) 913
റസ്റ്റ് (rc, –release) 937.94
സി (clang, -O3) 995.38
പൈത്തൺ3.10 (basel_multicore) 1170
പൈത്തൺ3.10 (basel_np_broadcast) 9680
ഹാസ്കെൽ (ghc, -O3) 13454
പൈത്തൺ3.10 (basel_np) 44270
പൈത്തൺ3.10 (basel_less_pythonic) 59250
പൈത്തൺ3.10 (basel) 68280

വളരെ മികച്ചത്! ചങ്ക് ചെയ്ത നമ്പൈ കോഡാണ് ഏറ്റവും വേഗതയേറിയ ക്രമാനുഗത പരിഹാരം! ഓർക്കുക, പൈത്തൺ കണക്കുകൂട്ടലുകൾ നടത്തുമ്പോൾ ഒരു ഇന്റർപ്രെറ്റർ മുഴുവനും പശ്ചാത്തലത്തിൽ പ്രവർത്തിക്കുന്നുണ്ട്.

റസ്റ്റും പൈത്തൺ മൾട്ടികോറും തമ്മിലുള്ള താരതമ്യം:

ഭാഷ (മില്ലിസെക്കൻഡ്) (മില്ലിസെക്കൻഡ്) (മില്ലിസെക്കൻഡ്) (മില്ലിസെക്കൻഡ്)
റസ്റ്റ് 12.3 111.2 1083 10970
പൈത്തൺ 1040 1173 2330 12629

വ്യക്തമായും, ചെറിയ മൂല്യങ്ങൾക്ക്, റസ്റ്റിന്റെ സമാന്തരീകരണ രീതി (ഓ.എസ്. ത്രെഡുകൾ വഴി) പൈത്തണിന്റെ പ്രോസസ് അധിഷ്ഠിത സമാന്തരതയേക്കാൾ വളരെ കാര്യക്ഷമമാണ്. എന്നാൽ വർദ്ധിക്കുമ്പോൾ ഈ വ്യത്യാസം കുറയുന്നു.

ഉപസംഹാരം

വ്യക്തമല്ലെങ്കിൽ, ഈ മുഴുവൻ ലേഖനവും പൈത്തൺ എങ്ങനെ പ്രവർത്തിക്കുന്നു എന്ന് അൽപ്പം മനസ്സിലാക്കാനുള്ള ഒരു പരിശീലനം മാത്രമായിരുന്നു. ദയവായി നിങ്ങളുടെ പൈത്തൺ കോഡ് അതിമിതമായി ഒപ്റ്റിമൈസ് ചെയ്തുകൊണ്ട് മാനേജറിനെ ഇംപ്രസ് ചെയ്യാൻ ശ്രമിക്കരുത്. എല്ലായ്പ്പോഴും ഒരു മികച്ച പരിഹാരം ഉണ്ടാകും, ഉദാഹരണത്തിന്

from math import pi

def basel_smart() -> float:
    return pi*pi / 6

താ-ദാ! മറ്റെല്ലാ ഫംഗ്ഷനുകളേക്കാളും ഓർഡറുകൾ കൂടുതൽ വേഗത്തിലും അനന്തമായി ലളിതവുമാണ്.

പ്രകടനത്തെക്കുറിച്ച് ചിന്തിക്കുമ്പോൾ, നിങ്ങൾ ശ്രദ്ധിക്കേണ്ടതുണ്ട്. നിങ്ങൾ അളന്നതിന് ശേഷം, അത് ഒരു പ്രശ്നമാണെന്ന് നിങ്ങൾക്ക് ഉറപ്പുണ്ടെങ്കിൽ മാത്രമേ പ്രകടനം ഒരു പ്രശ്നമാണെന്ന് ഊഹിക്കാതിരിക്കുക. അങ്ങനെയാണെങ്കിൽ, ഒരു ലളിതമായ പ്രശ്നത്തിന് import multiprocessing എന്ന് വേഗത്തിൽ ടൈപ്പ് ചെയ്ത് ബ്രൂട്ട് ഫോഴ്സ് ചെയ്യുന്നതിന് മുമ്പ്, പ്രശ്നം പരിഹരിക്കാനുള്ള ഒരു മികച്ച മാർഗ്ഗമുണ്ടോ എന്ന് എല്ലായ്പ്പോഴും പരിശോധിക്കുക.

കൂടാതെ, നിങ്ങളുടെ ആപ്ലിക്കേഷൻ ശരിക്കും അത്രയും പ്രകടന-നിർണായകമാണെങ്കിൽ, നിങ്ങൾ അത് പൈത്തണിൽ എഴുതുന്നത് ശരിയായിരിക്കില്ല. പ്രോട്ടോടൈപ്പിംഗിൽ മികച്ച പ്രകടനം നടത്താനുള്ള ഒരു ഉപകരണമായാണ് പൈത്തൺ സൃഷ്ടിച്ചത്, എക്സിക്യൂഷൻ വേഗതയല്ല. എന്നാൽ നിങ്ങൾക്ക് കാണാനാകുന്നതുപോലെ, മികച്ച പ്രകടനം നേടുന്നത് അസാധ്യമല്ല.

എന്തായാലും, ഈ സൈറ്റിലെ എന്റെ ആദ്യത്തെ യഥാർത്ഥ ലേഖനം നിങ്ങൾ ആസ്വദിച്ചുവെന്ന് ഞാൻ പ്രതീക്ഷിക്കുന്നു. ഈ “പ്രശ്നത്തിന്” നിങ്ങൾക്ക് ഒരു വേഗതയേറിയ പൈത്തൺ “പരിഹാരം” കണ്ടെത്തിയാൽ, എനിക്ക് ഒരു ഇമെയിൽ അയയ്ക്കുക!

അപ്ഡേറ്റ് ലോഗ്

തീയതി വിവരണം നന്ദി
08/02/2023 ബ്രോഡ്കാസ്റ്റിംഗ് വിഭാഗം ചേർത്തു, പൈത്തൺ ഏറ്റവും വേഗതയേറിയ പരിഹാരമായി മാറി u/mcmcmcmcmcmcmcmcmc_ & u/Allanon001
08/02/2023 time.time() എന്നത് time.perf_counter() ആക്കി മാറ്റി u/james_pic

✦ ഈ ലേഖനത്തിന്റെ ആശയരൂപീകരണം, ഗവേഷണം, എഴുത്ത്, അല്ലെങ്കിൽ എഡിറ്റിംഗ് എന്നിവയിൽ LLM-കൾ ഉപയോഗിച്ചിട്ടില്ല.