সরাসরি পর্বে যান
পর্ব ১৯ · ৭৯-এর মধ্যে · মডিউল ৩

NumPy-র গতি — memory, dtype, view আর কখন loop-ই ঠিক

NumPy Performance — Measuring, Memory Layout, Strides, dtype, Temporaries, Views, and When a Loop Is Fine

Taught in Bangla, this lesson shows how to time NumPy code properly, how C and Fortran order and strides decide memory access, and why column walks on big arrays were about nine times slower in one real run. It weighs float32 against float64 and integer overflow, measures temporary arrays with tracemalloc, and shows when loops, cumsum tricks, Numba or cProfile are the right tool.

এই পর্বে যা শিখবেন

  • time.perf_counter, timeit, warm-up আর median দিয়ে নির্ভরযোগ্যভাবে সময় মাপা, আর একটি run-এর সংখ্যার সীমা বোঝা
  • C order, Fortran order, strides আর contiguous array কী, আর বড় array-তে সারি বনাম কলামের গতির পার্থক্য কেন
  • float32 বনাম float64-এর মেমরি, গতি আর নির্ভুলতা, আর integer overflow-এর ঝুঁকি
  • +=, out=, view আর copy দিয়ে অস্থায়ী array আর বাড়তি মেমরি এড়ানো
  • কখন loop-ই ঠিক, কখন cumsum-জাতীয় কৌশল, কখন Numba; cProfile দিয়ে ধীর জায়গা খোঁজা আর np.memmap-এর ধারণা

মডিউল ৩-এর আগের পর্বগুলোতে NumPy-র array, indexing, vectorization আর broadcasting দেখেছেন (পর্ব ১৩ থেকে পর্ব ১৮)। সেখানে মূল কথা ছিল: Python-এর loop-এর বদলে পুরো array-এর ওপর একবারে কাজ চালালে কোড অনেক দ্রুত হয়। পর্ব ৯-এ এক run-এ সেই পার্থক্য প্রায় ৫০ গুণ দেখেছিলেন। কিন্তু বাস্তবে দুটি NumPy কোড, দেখতে প্রায় এক, গতিতে দশ গুণ আলাদা হতে পারে। কেউ ১৬ GB RAM-এর ল্যাপটপে “MemoryError” পান, অথচ ডেটা মাত্র ৩ GB. এই পর্ব সেই রহস্যগুলোর: মেমরিতে array কীভাবে বসে, dtype কী বদলায়, কোথায় লুকিয়ে নতুন array তৈরি হয়, আর কখন loop-ই সবচেয়ে সৎ সমাধান। গতির সাধারণ নিয়ম (Big-O, profiling-এর মূল ধারণা) আছে Python কোর্সের পারফরম্যান্স পর্বে; এখানে শুধু NumPy-র দিকটি।

১ · ঠিকভাবে মাপা — perf_counter, timeit, warm-up আর median

বাসা থেকে অফিসে রিকশায় কত সময় লাগে? একদিন মেপে “১৮ মিনিট” বললে ভুল হতে পারে: সেদিন হয়তো হরতাল ছিল, রাস্তা ফাঁকা। আরেকদিন জ্যামে ৪০ মিনিট। ঠিক উত্তর পেতে কয়েক দিন মাপতে হয়, আর মাঝের মানটি (median) নিতে হয়, যাতে একদিনের অস্বাভাবিক জ্যাম পুরো হিসাব নষ্ট না করে। কোডের সময় মাপাও ঠিক তেমন। একই কোড একই যন্ত্রে দুবার চালালে সময় আলাদা আসে, কারণ পেছনে অন্য program চলে, CPU-র গতি তাপমাত্রা আর বিদ্যুৎ-সাশ্রয়ের নিয়মে ওঠানামা করে, আর প্রথমবার চালানোর খরচ বাকিগুলোর চেয়ে আলাদা।

নির্ভরযোগ্য মাপজোকের (benchmark) চারটি নিয়ম:

  1. সঠিক ঘড়ি: time.perf_counter(), যা ছোট সময় মাপার জন্য সবচেয়ে সূক্ষ্ম ঘড়ি। time.time() দেয়ালের ঘড়ি; যন্ত্রের সময় ঠিক করা হলে সেটি লাফাতে পারে।
  2. Warm-up: প্রথম run প্রায়ই ধীর। Module import, মেমরি প্রথমবার ছোঁয়া, Numba-র মতো টুলের compile, সব খরচ প্রথমবারেই পড়ে। প্রথম run বাদ দিন, অথবা আলাদা করে লিখুন।
  3. কয়েকবার, তারপর median বা min: ৫–৭ বার চালিয়ে median দিন। Python-এর timeit-এর অফিসিয়াল নথি আবার min-কে বেশি গুরুত্ব দেয়, কারণ বেশি সময়গুলো সাধারণত অন্য program-এর হস্তক্ষেপ। Median বলে “সাধারণত কত”, min বলে “সবচেয়ে ভালো অবস্থায় কত”। দুটিই বৈধ; কোনটি দিচ্ছেন, সেটি লিখে দিন।
  4. একই input, একই ফল: দুটি সংস্করণ একই ডেটায় চালান, আর ফল সমান কি না np.allclose বা np.array_equal দিয়ে মিলিয়ে নিন। দ্রুত কিন্তু ভুল কোড দ্রুত নয়, ভুল।

Python-এর সঙ্গে আসা timeit এই কাজগুলো নিজেই করে: কোডটি অনেকবার চালায়, কয়েক দফায় ভাগ করে। Terminal থেকে লেখকের ল্যাপটপে একটি সত্যিকারের run (Python 3.14.4, NumPy 2.5.4):

~/bazarghar-ai$ python -m timeit -s "import numpy as np; a = np.random.default_rng(42).random(1_000_000)" "a.sum()"
1000 loops, best of 5: 227 usec per loop

-s-এর অংশ (setup) একবার চলে, সময়ে ধরা হয় না; মূল কোড ১০০০ বার করে পাঁচ দফা চলেছে, আর সেরা দফার গড় দেখানো হয়েছে: ১০ লাখ float-এর যোগ প্রায় ০.২৩ মিলিসেকেন্ডে। Jupyter-এ একই কাজ করে %timeit a.sum(), যা পর্ব ৮-এর line magic; সেটি গড় ± standard deviation দেখায়। এই পাতায় প্রতিটি মাপ time.perf_counter দিয়ে, কারণ ব্রাউজারের Python-এ ছোট আর স্পষ্ট কোডই সবচেয়ে ভালো শেখায়।

এই পাতার প্রতিটি সময় একটি run, একটি যন্ত্রে। আপনার পর্দায় অন্য সংখ্যা আসবে, আবার চালালেও বদলাবে। ব্রাউজারের Python (Pyodide, WebAssembly-তে চলা CPython) সাধারণ desktop Python-এর চেয়ে প্রায়ই ধীর, আর অনুপাতও আলাদা হতে পারে। তাই সংখ্যাগুলো থেকে শুধু দিক আর মাত্রা নিন (“কয়েক গুণ ধীর”), নির্দিষ্ট গুণফল নয়। আর কোনো অনুপাত দেখে সিদ্ধান্ত নেওয়ার আগে নিজের ডেটায়, নিজের যন্ত্রে মাপুন।

২ · মেমরিতে array কীভাবে বসে — C order, Fortran order আর strides

কম্পিউটারের মেমরি একটি লম্বা সারির ঘর, প্রতিটির একটি ঠিকানা, যেন একটি রেলগাড়ির বগির সারি। সেখানে “সারি আর কলাম” বলে কিছু নেই। তাহলে একটি দুই-মাত্রার (2D) array সেখানে বসে কীভাবে? উত্তর: তাকে একটি লম্বা ফিতায় খুলে রাখতে হয়। খোলার দুটি প্রচলিত পথ। একটিতে প্রথম সারির সব ঘর, তারপর দ্বিতীয় সারির সব: একে বলে row-major বা C order, কারণ C ভাষা এভাবে রাখে, আর NumPy-র default এটিই। অন্যটিতে প্রথম কলামের সব ঘর, তারপর দ্বিতীয় কলামের: column-major বা Fortran order, কারণ Fortran (আর MATLAB, R) এভাবে রাখে। এই বিন্যাসকে বলে memory layout.

একই 3 × 4 array, মেমরিতে দুই রকম ফিতা

C order (row-major) vs Fortran order (column-major), int64, 8 bytes per element

C order আর Fortran order-এর মেমরি বিন্যাস a = np.arange(12).reshape(3, 4) সারি 0 সারি 1 সারি 2 0 1 2 3 4 5 6 7 8 9 10 11 ↑ কলাম 2 C order (row-major): সারির পর সারি 0 1 2 3 4 5 6 7 8 9 10 11 সারিতে পরের ঘর +8 byte, কলামে পরের ঘর +32 byte a.strides = (32, 8) Fortran order (column-major): কলামের পর কলাম 0 4 8 1 5 9 2 6 10 3 7 11 কলামে পরের ঘর +8 byte, সারিতে পরের ঘর +24 byte np.asfortranarray(a).strides = (8, 24) সারি 1 = a[1, :] কলাম 2 = a[:, 2]
ছবি ১৯.১ — ওপরের ছক মানুষের চোখে array; নিচের দুটি ফিতা মেমরিতে তার আসল রূপ। C order-এ নীল সারিটি টানা চারটি ঘর, কিন্তু হলুদ কলামের ঘরগুলো চার ঘর পরপর ছড়ানো। Fortran order-এ ঠিক উল্টো। মাঝের ঘর 6 দুটিতেই আছে। মান একই, শুধু সাজানোর ক্রম আর strides আলাদা।

NumPy কীভাবে জানে কোন ঘর কোথায়? প্রতিটি array-এর সঙ্গে থাকে strides: প্রতিটি মাত্রায় এক ধাপ এগোতে কত byte লাফাতে হবে। ছবির C-order array-এ a.strides == (32, 8): পরের সারিতে যেতে ৩২ byte (৪টি ঘর × ৮ byte), পরের কলামে যেতে ৮ byte. তাই ঘর a[i, j]-এর ঠিকানা হলো শুরুর ঠিকানা থেকে

offset = i × strides[0] + j × strides[1] = 32·i + 8·j

যেমন a[2, 1]: 32·2 + 8·1 = 72 byte দূরে, মানে ফিতার দশম ঘর (৭২ / ৮ = ৯, শূন্য থেকে গুনলে), যার মান 9. নিচের ধাপে একটি সারি আর একটি কলাম ধরে হেঁটে দেখুন, কোথায় লাফ ছোট, কোথায় বড়।

1a = np.arange(12, dtype=np.int64).reshape(3, 4)2a.strides                  # (32, 8)3row = a[1, :]              # strides (8,)4col = a[:, 2]              # strides (32,)5big[:, j] on 6000 x 6000   # strides (48000,)
মান (Watch)
উপাদান
offset (byte)
আগের ঘর থেকে লাফ
৬৪-byte cache line
  1. Array আর তার strides. 3 × 4 array, int64, প্রতি ঘর ৮ byte. Strides (32, 8), তাই যেকোনো ঘরের ঠিকানা 32·i + 8·j. NumPy প্রতিটি indexing এই সূত্র দিয়েই করে।
  2. সারি 1-এর প্রথম ঘর। a[1, 0]: 32·1 + 8·0 = 32 byte. মান 4. ঠিকানাটি প্রথম ৬৪ byte-এর (line 0) ভেতরে।
  3. সারি ধরে এগোনো। পরের তিনটি ঘর 40, 48, 56 byte-এ: প্রতিবার মাত্র +8. চারটি ঘরই একই ৬৪-byte টুকরোয়, তাই CPU একবার আনলেই চারটি পায়। row-এর strides (8,), মানে এটি একটি টানা (contiguous) view.
  4. কলাম 2-এর প্রথম ঘর। a[0, 2]: 32·0 + 8·2 = 16 byte, মান 2.
  5. কলাম ধরে এগোনো। a[1, 2] আছে 48-এ, a[2, 2] আছে 80-এ: প্রতিবার +32. তৃতীয় ঘরটি প্রথম ৬৪ byte পেরিয়ে line 1-এ। এই ছোট array-তে ক্ষতি সামান্য, কারণ সব ঘর মিলে মাত্র ৯৬ byte.
  6. বড় array-তে একই হাঁটা। ৬০০০ × ৬০০০ float64 array-তে এক সারি ৪৮,০০০ byte. একটি কলাম হাঁটলে প্রতিটি উপাদান আগেরটি থেকে ৪৮,০০০ byte দূরে, তাই প্রতিটির জন্য আলাদা ৬৪-byte টুকরো আনতে হয়, যার ৮ byte কাজে লাগে আর ৫৬ byte অপচয়। গতির পার্থক্যের মূল কারণ এটিই।

দুটি শব্দ পরিষ্কার করে নিই। যে array-এর সব ঘর মেমরিতে কোনো ফাঁক ছাড়াই পাশাপাশি, সে contiguous. NumPy এটি জানায় flags দিয়ে: a.flags.c_contiguous (C order-এ টানা) আর a.flags.f_contiguous (Fortran order-এ টানা)। একটি C-order array-এর transpose a.T নতুন কোনো মেমরি নেয় না; NumPy শুধু strides উল্টে দেয়, (8, 32), ফলে a.T হয়ে যায় F-contiguous. আর a[:, 2] বা a[:, ::2] হলো non-contiguous: মাঝে ফাঁক রেখে ঘর বাছা। এগুলো view, অর্থাৎ মূল array-এর মেমরির দিকেই তাকানো (পর্ব ১৪-এ দেখেছেন), কিন্তু হাঁটতে বেশি লাফাতে হয়।

৩ · সারি বনাম কলাম — cache আর বড় array-তে লাফানোর দাম

মুদি দোকানদার গুদাম থেকে একটি চালের বস্তা আনতে গেলে একসঙ্গে পাশের কয়েকটি বস্তাও নিয়ে আসেন, কারণ যাওয়া-আসাতেই আসল সময় যায়। CPU-ও এমন করে। মূল মেমরি (RAM) CPU-র তুলনায় অনেক ধীর, তাই CPU-র গায়ে ছোট আর খুব দ্রুত একটি মেমরি থাকে, যার নাম cache. RAM থেকে কিছু আনতে হলে CPU একটি ঘর নয়, একটি পুরো টুকরো আনে, যাকে বলে cache line; আজকের বেশির ভাগ x86 আর অনেক ARM প্রসেসরে এটি ৬৪ byte. পরের দরকারি ঘর যদি সেই টুকরোতেই থাকে, সেটি প্রায় বিনা খরচে মেলে। Cache কীভাবে কাজ করে, তার পুরো গল্প আছে Computer Architecture কোর্সের cache-এর মূল কথা আর cache-সচেতন প্রোগ্রামিং পর্বে।

তাই C-order array-তে সারি ধরে হাঁটা মানে একটি টুকরো আনলেই আটটি float64 পাওয়া। কলাম ধরে হাঁটা মানে প্রতিটি সংখ্যার জন্য নতুন টুকরো। ছোট array পুরোটাই cache-এ এঁটে যায়, তখন পার্থক্য প্রায় চোখে পড়ে না। বড় array-তে পড়ে। লেখকের ল্যাপটপে একটি ৬০০০ × ৬০০০ float64 array (২৮৮ MB) নিয়ে সত্যিকারের run (Python 3.14.4, NumPy 2.5.4, প্রতিটি সারিতে warm-up-এর পর ৭ বারের median):

~/bazarghar-ai$ python layout_bench.py
Python 3.14.4 | NumPy 2.5.4
shape (6000, 6000) dtype float64 MB 288.0
strides (48000, 8) C? True F? False
row slices   median     22.9 ms  (min    22.1, max    29.0)
col slices   median    215.8 ms  (min   214.0, max   223.8)
sum(axis=1)  median     19.1 ms  (min    18.9, max    26.4)
sum(axis=0)  median     14.1 ms  (min    13.5, max    14.8)
F copy strides (8, 48000) C? False F? True
F col slices median     22.1 ms
same totals: True True

প্রথম দুটি লাইন হুবহু একই কাজ: ৬০০০টি টুকরোর প্রতিটির যোগফল, Python-এর একটি loop দিয়ে। a[i, :].sum() (সারি) প্রায় ২৩ ms, a[:, j].sum() (কলাম) প্রায় ২১৬ ms, মানে এই run-এ প্রায় ৯ গুণ ধীর। একই array-কে np.asfortranarray দিয়ে Fortran order-এ কপি করলে কলামই হয়ে যায় টানা, আর কলাম-loop নেমে আসে প্রায় ২২ ms-এ। অর্থাৎ ধীরতার কারণ “কলাম” নয়, “মেমরিতে লাফানো”।

কিন্তু তৃতীয় আর চতুর্থ লাইন একটি গুরুত্বপূর্ণ সূক্ষ্মতা দেখায়। NumPy-কে পুরো কাজটি একবারে দিলে (a.sum(axis=0), প্রতিটি কলামের যোগফল) সে নিজেই বুদ্ধি খাটায়: কলাম ধরে না হেঁটে সারির পর সারি টানা পড়ে, আর ৬০০০টি চলমান যোগফল একসঙ্গে বাড়ায়। তাই এখানে axis=0 মোটেই ধীর নয়, এই run-এ বরং একটু দ্রুত। শিক্ষাটি তাই দুই ভাগে: (১) নিজের loop-এ বড় array-এর টুকরো হাঁটতে হলে মেমরির ক্রম মেনে হাঁটুন; (২) সম্ভব হলে পুরো কাজটি NumPy-কে একবারে দিন, কারণ সে মেমরির ক্রম আপনার চেয়ে ভালো জানে।

এবার ব্রাউজারে একটি মাঝারি (২০০০ × ২০০০, ৩২ MB) array নিয়ে একই পরীক্ষা চালিয়ে দেখুন। প্রথমবার চালালে NumPy নামাতে কয়েক সেকেন্ড লাগতে পারে।

import time
import numpy as np

a = np.random.default_rng(42).random((2000, 2000))   # C order, float64
print("a strides:", a.strides, "| C:", a.flags.c_contiguous,
      "F:", a.flags.f_contiguous)
print("a[:, 0] strides:", a[:, 0].strides, "| C:", a[:, 0].flags.c_contiguous)
print("a.T strides:", a.T.strides, "| F:", a.T.flags.f_contiguous)

def rows():
    return [a[i, :].sum() for i in range(a.shape[0])]

def cols():
    return [a[:, j].sum() for j in range(a.shape[1])]

def median_ms(f, repeat=5):
    f()                                   # warm-up: প্রথম run বাদ
    times = []
    for _ in range(repeat):
        t0 = time.perf_counter()
        f()
        times.append(time.perf_counter() - t0)
    times.sort()
    return times[len(times) // 2] * 1000  # মাঝের মান

r, c = median_ms(rows), median_ms(cols)
print(f"সারি ধরে : {r:6.1f} ms")
print(f"কলাম ধরে: {c:6.1f} ms  (প্রায় {c / r:.1f} গুণ)")
print("ফল এক?", np.allclose(rows(), a.sum(axis=1)),
      np.allclose(cols(), a.sum(axis=0)))

ব্রাউজারে (Pyodide) একটি run-এর আসল আউটপুট:

a strides: (16000, 8) | C: True F: False
a[:, 0] strides: (16000,) | C: False
a.T strides: (8, 16000) | F: True
সারি ধরে :    5.6 ms
কলাম ধরে:   18.2 ms  (প্রায় 3.2 গুণ)
ফল এক? True True

প্রথম তিন লাইন অংশ ২-এর কথাগুলো মেলায়: এক সারি ২০০০ × ৮ = ১৬,০০০ byte, তাই strides (16000, 8); একটি কলাম a[:, 0] view, কিন্তু C-contiguous নয়, কারণ তার প্রতিটি ঘর আগেরটি থেকে ১৬,০০০ byte দূরে; আর a.T কোনো copy ছাড়াই শুধু strides উল্টে F-contiguous. সময়ে এই run-এ সারি প্রায় ৫.৬ ms, কলাম প্রায় ১৮.২ ms, মানে প্রায় ৩ গুণ। লেখকের ল্যাপটপের ২৮৮ MB array-এর ৯ গুণের চেয়ে কম, কারণ এখানে array ছোট (৩২ MB), তার একটি অংশ cache-এ থেকে যায়, আর ব্রাউজারের Python-এ প্রতিটি .sum() call-এর নিজের স্থির খরচ অনুপাতে বেশি। দিকটি কিন্তু একই। শেষ লাইন দেখায় দুই পথের ফল এক, তাই তুলনাটি অর্থপূর্ণ।

৪ · dtype বাছাই — float32 বনাম float64, আর integer overflow

প্রতিটি NumPy array-এর একটি dtype থাকে: প্রতিটি ঘরে কোন ধরনের সংখ্যা, কত byte-এ। float64 (Python-এর সাধারণ float-এর সমান) প্রতি সংখ্যায় ৮ byte নেয় আর প্রায় ১৫–১৬টি দশমিক অঙ্ক পর্যন্ত নির্ভুল। float32 নেয় ৪ byte, নির্ভুলতা প্রায় ৭টি অঙ্ক। ফিতা যত সরু, একই cache line-এ তত বেশি সংখ্যা ধরে, আর RAM থেকে তত কম আনতে হয়। লেখকের ল্যাপটপে ২ কোটি সংখ্যা নিয়ে একটি সত্যিকারের run (warm-up-এর পর ৭ বারের median):

~/bazarghar-ai$ python dtype_bench.py
MB float64 160.0 | float32 80.0
sum f64    median    10.1 ms
sum f32    median     6.4 ms
x*2+1 f64  median    91.7 ms
x*2+1 f32  median    45.3 ms

মেমরি ঠিক অর্ধেক, আর এই run-এ x * 2 + 1 প্রায় দ্বিগুণ দ্রুত। এমন হিসাবে CPU-র গণনা নয়, মেমরি আনা-নেওয়াই আসল বাধা, তাই অর্ধেক byte মানে প্রায় অর্ধেক সময়। Deep learning-এ এ কারণেই float32 (আর আরও ছোট float16 বা bfloat16) প্রচলিত; ছবির pixel বা মডেলের weight-এর জন্য ৭ অঙ্কই যথেষ্ট। কিন্তু দাম আছে: ভুল জমে। ধাপে ধাপে দেখুন, float32-এ বারবার 0.1 যোগ করলে কী হয়।

1s = np.float32(0)2for k in range(n):3    s = s + np.float32(0.1)
মান (Watch)
যোগের সংখ্যা
float32-এ s
আসল মান
ভুল
  1. প্রথম যোগ। 0.1 binary-তে অসীম ভগ্নাংশ (পর্ব ৯-এ দেখেছেন)। float32-এ তার সবচেয়ে কাছের মান 0.1000000015. শুরুতেই সামান্য ভুল।
  2. তিনবার। s = 0.3000000119. প্রতিটি যোগের পর ফল আবার সবচেয়ে কাছের float32-এ গোল হয়, আর ছোট ছোট ভুল জুড়তে থাকে।
  3. দশবার। s = 1.000000119: ভুল এখন সপ্তম অঙ্কে। float32-এর নির্ভুলতা প্রায় ৭ অঙ্ক, তাই এটি প্রত্যাশিত।
  4. এক হাজার বার। s = 99.99904633, আসল 100. সংখ্যা যত বড়, দুটি পাশাপাশি float32-এর দূরত্ব তত বড়, তাই প্রতিটি গোল করায় তত বেশি হারায়।
  5. s ≈ 262,144 (218)-এ পৌঁছালে। এখানে পাশাপাশি দুটি float32-এর দূরত্ব 0.03125. 0.1-এর সবচেয়ে কাছের ধাপ 0.09375, তাই প্রতিটি যোগে প্রায় ৬% হারায়। (দশ লাখ যোগে s এত বড় হয় না; এই ধাপ আর পরেরটি আরও বড় যোগফলের ছবি।)
  6. s = 2,097,152 (221)-এ। দূরত্ব 0.25. 0.1 তার অর্ধেকেরও কম, তাই যোগফল গোল হয়ে আগের জায়গাতেই ফেরে: যোগ করলেও s একটুও বাড়ে না। একই কারণে np.float32(16777216) + 1 হয় 16777216.
  7. দশ লাখ যোগের পর। একটির পর একটি যোগে (যেমন np.cumsum) ফল 100958.34, আসল 100000 থেকে প্রায় ৯৫৮ বেশি। অথচ একই float32 array-এ np.sum দেয় 100000.01, কারণ সে জোড়ায় জোড়ায় যোগ করে (pairwise summation), ফলে বড় আর ছোট সংখ্যা কম মেশে।

শেষ ধাপের পার্থক্যটি NumPy-র অফিসিয়াল নথিতেও লেখা: float-এর np.sum অনেক ক্ষেত্রে pairwise summation ব্যবহার করে (বিশেষত axis না দিলে), যা একটির পর একটি যোগের চেয়ে অনেক কম ভুল জমায়। কিন্তু np.cumsum-এর মতো চলমান যোগ, বা নিজের লেখা loop, সেই সুবিধা পায় না। এখন integer-এর দিক। Python-এর int যত বড় চান হয়; NumPy-র integer হয় না। int32-এর সীমা 2,147,483,647, তার বেশি হলে ফল চুপচাপ ঘুরে ঋণাত্মক হয়ে যায়, যাকে বলে integer overflow. Array-এর হিসাবে NumPy এর জন্য কোনো error বা সতর্কবার্তা দেয় না। নিচের কোডে দুটি ফাঁদই দেখুন।

import numpy as np

v32 = np.full(1_000_000, 0.1, dtype=np.float32)
v64 = np.full(1_000_000, 0.1)                     # float64
print("মেমরি:", v32.nbytes, "বনাম", v64.nbytes, "bytes")
print("float32-এ 0.1 আসলে:", f"{float(np.float32(0.1)):.12f}")

run32 = np.cumsum(v32)            # একটির পর একটি যোগ, float32-এ
print("cumsum float32 :", run32[-1])
print("np.sum float32 :", v32.sum())              # pairwise যোগ
print("np.sum float64 :", v64.sum())
print("আসল মান        : 100000")
print("2**21 + 0.1    :", np.float32(2**21) + np.float32(0.1))

qty = np.array([40_000, 50_000], dtype=np.int32)
print("int32 বর্গ:", qty * qty)                    # চুপচাপ উপচে পড়ে
print("int64 বর্গ:", qty.astype(np.int64) ** 2)
print("int32 সীমা:", np.iinfo(np.int32).max)
u = np.array([250, 10], dtype=np.uint8)
print("uint8 + 10:", u + np.uint8(10))            # 260 → 4

ব্রাউজারে (Pyodide) একটি run-এর আসল আউটপুট:

মেমরি: 4000000 বনাম 8000000 bytes
float32-এ 0.1 আসলে: 0.100000001490
cumsum float32 : 100958.34
np.sum float32 : 100000.01
np.sum float64 : 100000.00000000003
আসল মান        : 100000
2**21 + 0.1    : 2.097152e+06
int32 বর্গ: [ 1600000000 -1794967296]
int64 বর্গ: [1600000000 2500000000]
int32 সীমা: 2147483647
uint8 + 10: [ 4 20]

এক লাইন এক লাইন করে। float32 array ঠিক অর্ধেক মেমরি নেয়: ৪০ লাখ বনাম ৮০ লাখ byte. float32-এ 0.1 আসলে 0.100000001490, শুরুতেই সামান্য বেশি। চলমান যোগে (cumsum) ফল 100958.34, আসল মানের চেয়ে প্রায় ৯৫৮ বা ১% বেশি; ঠিক stepper-এর শেষ ধাপের সংখ্যা। একই array-এ np.sum pairwise যোগে দেয় 100000.01, আর float64 দেয় 100000.00000000003, যেখানে ভুল দশমিকের পর ১১তম ঘরে। 221-এর সঙ্গে 0.1 যোগ করলে ফল বদলায়নি (NumPy float32-টি 2.097152e+06 আকারে ছেপেছে, মান 2097152). পূর্ণসংখ্যার অংশে 50,000-এর বর্গ ২৫০ কোটি, যা int32-এর সীমা 2,147,483,647 ছাড়ায়; ফল ঘুরে হয়েছে 2,500,000,000 − 232 = −1,794,967,296, কোনো error বা সতর্কবার্তা ছাড়াই। int64-এ ফল ঠিক। আর uint8-এ 250 + 10 = 260 ঘুরে 4. বাজারঘরের “পরিমাণ × দাম পয়সায়” কলাম int32-এ রাখলে এই ভুল খুব বাস্তব। মনে রাখুন, ব্রাউজারের ৩২-বিট Python-এ NumPy-র default integer-ই int32, তাই এখানে dtype স্পষ্ট লেখা জরুরি।

মূল ধারণা

dtype বেছে নিন উদ্দেশ্য দেখে, অভ্যাস দেখে নয়। মেমরি আর গতি দরকার, আর ৭ অঙ্কই যথেষ্ট (ছবি, মডেলের weight): float32. যোগফল, গড়, পরিসংখ্যান, টাকা-পয়সা: float64 বা পূর্ণসংখ্যার পয়সা, আর float32 array-এর যোগফলও চাইলে v.sum(dtype=np.float64). পূর্ণসংখ্যায় সর্বোচ্চ সম্ভাব্য মান আগে হিসাব করুন (পরিমাণ × দাম × দিন), np.iinfo দিয়ে সীমা মিলিয়ে নিন, সন্দেহ হলে int64.

৫ · অস্থায়ী array এড়ানো — in-place, out= আর মেমরির হিসাব

রান্নাঘরে প্রতিটি ধাপের জন্য নতুন বাটি নিলে শেষে একগাদা বাটি ধুতে হয়, আর তাকে জায়গাও লাগে। NumPy-র expression-ও প্রতিটি ধাপে নতুন “বাটি” নেয়। c = a * 2 + b লিখলে NumPy আগে a * 2 হিসাব করে একটি নতুন array-এ রাখে, তারপর তার সঙ্গে b যোগ করে আরেকটি নতুন array বানায়, যা c হয়। মাঝের array-টির কোনো নাম নেই, কাজ শেষে ফেলে দেওয়া হয়; একে বলে temporary array. ছোট array-এ এটি নগণ্য। ১০ কোটি সংখ্যার array-এ প্রতিটি temporary ৮০০ MB.

সমাধান in-place operation: নতুন array না বানিয়ে আগের array-এর ঘরেই ফল লেখা। c += b হলো np.add(c, b, out=c), আর প্রতিটি ufunc (NumPy-র ঘরে ঘরে চলা function, যেমন np.add, np.multiply, np.sqrt) out= নিয়ে ঠিক করে দেওয়া array-এ ফল রাখতে পারে। ধাপে ধাপে মেমরির হিসাব দেখুন, ১০ লাখ float64-এর array ধরে (প্রতিটি ৮ MB).

1c = a * 2 + b          # সাধারণ লেখা2c = np.multiply(a, 2)  # একটিই নতুন buffer3c += b                 # সেখানেই যোগ4np.multiply(a, 2, out=buf); np.add(buf, b, out=buf)
মান (Watch)
এই ধাপে নতুন array
এখন বাড়তি মেমরি
চূড়া (peak)
  1. a * 2. NumPy একটি নতুন ৮ MB array বানিয়ে প্রতিটি ঘরে a-এর দ্বিগুণ লেখে। এর কোনো নাম নেই: এটিই temporary.
  2. tmp + b. যোগফলের জন্য আরেকটি নতুন ৮ MB array, যা হবে c. এই মুহূর্তে a, b ছাড়াও দুটি নতুন array একসঙ্গে বেঁচে আছে: ১৬ MB বাড়তি।
  3. Temporary মুক্ত। Expression শেষ, tmp-কে আর কেউ দেখছে না, তাই তার মেমরি ফেরত যায়। থাকে শুধু c, কিন্তু চূড়া (peak) ১৬ MB হয়ে গেছে; RAM কম থাকলে এই চূড়াতেই MemoryError হয়।
  4. দুই ধাপে ভাঙা। np.multiply(a, 2) একটি ৮ MB array বানায়, যা সরাসরি c. এখনো নতুন array একটিই।
  5. c += b. যোগফল লেখা হয় c-এর নিজের ঘরেই, নতুন কোনো array নেই। ফল হুবহু এক, চূড়া অর্ধেক: ৮ MB.
  6. out= দিয়ে আগে বানানো buffer. একই হিসাব যদি loop-এ বারবার চলে (প্রতিটি batch-এ), একটি buf = np.empty_like(a) একবার বানিয়ে প্রতিবার out=buf দিলে প্রতি call-এ নতুন মেমরি শূন্য।

এই হিসাব শুধু মনে মনে নয়, মাপা যায়। Python-এর tracemalloc module মেমরি বরাদ্দ গোনে, আর NumPy তার array-এর মেমরি tracemalloc-কে জানায়। নিচের কোড তিনটি পথের চূড়া মাপে; nbytes দিয়ে আগেই অনুমান করুন: প্রতিটি array ৮ MB হলে উত্তর কত হওয়া উচিত?

import tracemalloc
import numpy as np

n = 1_000_000
rng = np.random.default_rng(42)
a, b = rng.random(n), rng.random(n)
print("একটি array:", a.nbytes / 1e6, "MB")

def peak_mb(f):
    tracemalloc.start()
    f()
    peak = tracemalloc.get_traced_memory()[1]
    tracemalloc.stop()
    return peak / 1e6

def out_of_place():
    return a * 2 + b                 # a*2 একটি temporary, তারপর নতুন ফল

def one_buffer():
    c = np.multiply(a, 2)            # একটিই নতুন array
    c += b                           # সেখানেই in-place যোগ
    return c

buf = np.empty_like(a)               # আগে থেকে বানানো buffer
def reuse_out():
    np.multiply(a, 2, out=buf)
    np.add(buf, b, out=buf)
    return buf

for f in (out_of_place, one_buffer, reuse_out):
    print(f"{f.__name__:13s} peak {peak_mb(f):5.1f} MB")
print("ফল এক?", np.array_equal(out_of_place(), reuse_out()))

ব্রাউজারে (Pyodide) একটি run-এর আসল আউটপুট:

একটি array: 8.0 MB
out_of_place  peak  16.0 MB
one_buffer    peak   8.0 MB
reuse_out     peak   0.0 MB
ফল এক? True

মাপ ঠিক nbytes-এর হিসাব মিলিয়ে দিয়েছে। a * 2 + b-এর চূড়া ১৬ MB: একটি ৮ MB temporary আর একটি ৮ MB ফল, এক মুহূর্তে দুটিই বেঁচে। দুই ধাপে ভাঙলে (np.multiply, তারপর +=) চূড়া ৮ MB, শুধু ফলের array. আর আগে বানানো buf-এ out= দিলে function-এর ভেতরে নতুন কোনো array-ই তৈরি হয়নি: ০ MB. তিনটির ফল হুবহু এক। ১০ লাখ সংখ্যায় এটি ছোট সংখ্যা, কিন্তু অনুপাতটি আকারের সঙ্গে বাড়ে: ১০ কোটি সংখ্যায় একই কোডে চূড়া হতো ১.৬ GB বনাম ৮০০ MB বনাম শূন্য। একটি সাবধানতা: reuse_out প্রতিবার একই buf ফেরত দেয়, তাই পরের call আগের ফল মুছে লেখে; ফল রাখতে চাইলে .copy() করে রাখুন।

লেখকের ল্যাপটপে ৫০ লাখ সংখ্যা (প্রতিটি array ৪০ MB) নিয়ে একই মাপ দিয়েছিল চূড়া ৮০ MB বনাম ৪০ MB বনাম প্রায় শূন্য, আর সময়ে a * 2 + b প্রায় ২৩.৫ ms, আগে বানানো buffer-এ out= দিয়ে প্রায় ৬.৬ ms (warm-up-এর পর ৭ বারের median, একটি run). নতুন মেমরি চাওয়া আর প্রথমবার ছোঁয়ারও খরচ আছে, তাই in-place শুধু মেমরিই নয়, প্রায়ই সময়ও বাঁচায়।

In-place-এর তিনটি সাবধানতা। (১) এটি মূল ডেটা বদলে দেয়: a *= 2-এর পর আসল a আর নেই, আর a যদি অন্য array-এর view হয়, সেই array-ও বদলায়। Function-এর ভেতরে input-কে in-place বদলানো একটি লুকানো side effect, তাই সেখানে নিজের buffer ব্যবহার করুন। (২) dtype বদলায় না: integer array-এ i += 0.5 লিখলে NumPy দেয় UFuncTypeError: Cannot cast ufunc 'add' output from dtype('float64') to dtype('int64') with casting rule 'same_kind' (লেখকের যন্ত্রে সত্যিকারের run; ব্রাউজারে default int32 বলে সেখানে লেখা থাকবে int32). (৩) ছোট array-এ এই কৌশলে লাভ প্রায় নেই, অথচ কোড কঠিন হয়। বড় array আর বারবার চলা হিসাবেই এটি কাজের।

৬ · View আর copy — গতির চোখে আবার

পর্ব ১৪-এ দেখেছেন, slicing (a[10:20], a[:, ::2]) একটি view দেয়, যা নতুন মেমরি না নিয়ে শুধু নতুন শুরু-ঠিকানা আর strides দিয়ে মূল array-এর দিকে তাকায়; আর boolean mask বা fancy index (a[[3, 7, 9]]) সবসময় একটি copy দেয়। গতির দিক থেকে এর মানে সোজা: view তৈরি প্রায় বিনা খরচে, array যত বড়ই হোক; copy-র খরচ তার আকারের সমানুপাতিক। কয়েকটি জায়গা যেখানে লুকিয়ে copy হয়:

  • reshape আর ravel: contiguous array-এ এরা view দেয়। কিন্তু non-contiguous array-এ (যেমন a.T বা a[:, ::2]) নতুন আকারটি strides দিয়ে বোঝানো না গেলে NumPy চুপচাপ copy বানায়। flatten সবসময় copy.
  • np.ascontiguousarray(x): x আগেই C-contiguous হলে সেটিই ফেরত দেয়, না হলে copy. অনেক library (BLAS, C extension, কিছু scikit-learn অংশ) টানা array চায়, আর না পেলে নিজেরাই copy করে নেয়। একবার নিজে বানিয়ে বারবার ব্যবহার করলে বারবারের copy বাঁচে।
  • dtype বদলানো: x.astype(np.float32) সবসময় নতুন array (dtype একই হলেও, যদি না copy=False দেন)।
  • যাচাই: np.shares_memory(a, b) বলে দুটি array একই মেমরি ভাগ করছে কি না; b.base is a বলে b সরাসরি a-এর view কি না।

উল্টো দিকের ফাঁদও আছে। একটি বিশাল array থেকে ছোট একটি slice রেখে বাকিটা ফেলে দিতে চাইলে, slice-টি view হওয়ায় পুরো মূল array মেমরিতে আটকে থাকে। ৮০০ MB array থেকে ১০টি সারি রাখতে চাইলে small = big[:10].copy() লিখুন, তারপর del big; তখনই ৮০০ MB মুক্ত হয়।

৭ · কখন loop-ই ঠিক — cumsum-এর কৌশল, Numba আর Cython

“NumPy-তে loop লিখবেন না” কথাটি অর্ধেক সত্য। তিন রকম ক্ষেত্রে সাধারণ Python loop পুরোপুরি ঠিক, কখনো সবচেয়ে ভালো:

  • ছোট ডেটা: ২০টি জেলা বা ৫০টি পণ্যের ওপর loop কয়েক মাইক্রোসেকেন্ডের কাজ। Vectorize করে লাভ নেই, পড়তে কঠিন হয়। আর প্রতিটি NumPy call-এর নিজের একটি স্থির খরচ আছে, তাই খুব ছোট array-এ সাধারণ Python প্রায়ই দ্রুত।
  • আগেভাগে থামা (early exit): “প্রথম যে অর্ডারটি ১০,০০০ টাকা ছাড়ায়” খুঁজতে loop প্রথম মিলেই থামে। np.argmax(x > 10000) পুরো array পরীক্ষা করে, কারণ আগে সম্পূর্ণ boolean array বানায়। মিল যদি সাধারণত শুরুর দিকে থাকে, loop জিততে পারে।
  • ধারাবাহিক নির্ভরতা: আজকের মান যদি গতকালের ফলের ওপর নির্ভর করে, আর সেই নির্ভরতায় একটি সিদ্ধান্ত থাকে, তাহলে সব ঘর একসঙ্গে হিসাব করা যায় না।

শেষেরটির একটি বাস্তব উদাহরণ নেওয়া যাক। বাজারঘরের গুদামে একটি পণ্যের স্টক ১০০; প্রতিদিন কিছু বিক্রি হয়। দুই রকম নিয়ম ভাবুন। (ক) প্রতি ৭ দিনে নির্দিষ্ট দিনে নতুন চালান আসে আর স্টক আবার ১০০ হয়। রিসেটের দিনগুলো আগে থেকেই জানা। (খ) স্টক ২০-এর নিচে নামলেই সঙ্গে সঙ্গে ১০০-তে ভরা হয়। এখানে কবে রিসেট হবে, তা নির্ভর করে আগের দিনের স্টকের ওপর।

(ক)-কে vectorize করা যায়, যদিও প্রথম দেখায় ধারাবাহিক মনে হয়। চাবি হলো cumulative sum: np.cumsum(sales) শুরু থেকে প্রতিটি দিন পর্যন্ত মোট বিক্রি দেয়। প্রতিটি অংশের (দুই চালানের মাঝের দিনগুলো) বিক্রি বের করতে মোট থেকে অংশ শুরুর আগের মোট বাদ দিলেই হয়। আর “আমার অংশ কোন দিন শুরু হয়েছিল” বের করে np.maximum.accumulate: এটি চলমান সর্বোচ্চ, cumsum-এর ভাই। প্রতিটি ufunc-এর .accumulate আছে (np.add.accumulate হলো cumsum, np.multiply.accumulate হলো cumprod). (খ)-তে এমন কোনো কৌশল নেই, কারণ রিসেট ঘটবে কি না তা জানতে হলে আগে আগের দিনের ফল জানতে হবে।

import time
import numpy as np
rng = np.random.default_rng(42)
days = 1_000_000
sales = rng.integers(0, 15, size=days)
restock = np.arange(days) % 7 == 0        # প্রতি ৭ দিনে চালান: স্টক আবার 100

def stock_loop(sales, restock):           # ক) রিসেটের দিন আগেই জানা
    out, s = [], 100
    for x, r in zip(sales.tolist(), restock.tolist()):
        if r:
            s = 100
        s -= x
        out.append(s)
    return np.array(out)
def stock_vec(sales, restock):            # একই কাজ cumsum দিয়ে
    c = np.cumsum(sales)
    start = np.maximum.accumulate(np.where(restock, np.arange(sales.size), 0))
    before = np.where(start > 0, c[start - 1], 0)   # অংশ শুরুর আগের জমা
    return 100 - (c - before)
def stock_threshold(sales, low=20):       # খ) রিসেট নির্ভর করে আগের মানের ওপর
    out, s = [], 100
    for x in sales.tolist():
        s -= x
        if s < low:                       # সিদ্ধান্তের জন্য আগের s লাগবেই
            s = 100
        out.append(s)
    return out
t0 = time.perf_counter(); s1 = stock_loop(sales, restock)
t1 = time.perf_counter(); s2 = stock_vec(sales, restock)
t2 = time.perf_counter(); s3 = stock_threshold(sales); t3 = time.perf_counter()
print("ক) ফল এক?", np.array_equal(s1, s2), s1[:9])
print(f"   loop {(t1 - t0) * 1000:.1f} ms | cumsum {(t2 - t1) * 1000:.1f} ms")
print("খ) threshold:", s3[:9], f"{(t3 - t2) * 1000:.1f} ms")

ব্রাউজারে (Pyodide) একটি run-এর আসল আউটপুট (এখানে warm-up নেই, প্রতিটি সময় একবারের):

ক) ফল এক? True [99 88 79 73 67 55 54 90 87]
   loop 104.7 ms | cumsum 23.5 ms
খ) threshold: [99, 88, 79, 73, 67, 55, 54, 44, 41] 36.4 ms

(ক)-তে loop আর cumsum হুবহু এক ফল দিয়েছে। প্রথম ৭ দিন স্টক 99 থেকে 54-এ নামে, অষ্টম দিন (index 7, ৭ দিয়ে বিভাজ্য) নতুন চালানে আবার ১০০, তাই সেদিনের বিক্রি বাদে 90, তারপর 87. এই run-এ loop প্রায় ১০৫ ms, cumsum সংস্করণ প্রায় ২৪ ms, মানে প্রায় ৪ গুণ; এটি একবারের মাপ, warm-up ছাড়া। লাভটি এখানে পর্ব ৯-এর loop বনাম NumPy-র চেয়ে অনেক কম, কারণ cumsum সংস্করণ কয়েকটি পূর্ণ array (চলমান যোগ, অংশের শুরু, আগের জমা) আর তাদের temporary বানায়; গতির সঙ্গে মেমরির দামও দিতে হয়। (খ)-তে প্রথম ৭ দিন (ক)-এর মতোই, কিন্তু অষ্টম দিনে স্টক 54 থেকে 44-এ নামে, কারণ এখানে রিসেট হয় শুধু ২০-এর নিচে নামলে। এর কোনো cumsum রূপ নেই। আর লক্ষ করুন: দশ লাখ দিনের জন্য loop নিয়েছে প্রায় ৩৬ ms. অনেক কাজে এটুকুই যথেষ্ট; তখন পরিষ্কার loop-ই সবচেয়ে ভালো সমাধান।

যখন vectorize করা যায় না — Numba আর Cython

(খ)-এর মতো কাজ দশ লাখ নয়, দশ কোটি দিনের হলে Python loop-এর খরচ সত্যিই গায়ে লাগে। তখন একটি পথ Numba: একটি library, যা একটি Python function-কে প্রথম call-এর সময় যন্ত্রের নিজের কোডে (machine code) অনুবাদ করে, LLVM নামের compiler কাঠামো দিয়ে। একে বলে JIT compilation (just-in-time, চালানোর মুহূর্তে compile). Function-এর ওপরে @njit বসালেই হয়, যদি function-টি Numba-র বোঝা অংশের মধ্যে থাকে: সংখ্যা, NumPy array, সাধারণ loop আর শর্ত। লেখকের ল্যাপটপে ২০ লাখ দিনের threshold-নিয়ম নিয়ে একটি সত্যিকারের run (লেখার সময়ে numba 0.68.1, NumPy 2.5.4, Python 3.14.4; Python loop-এ ৩ বারের, Numba-য় ৫ বারের median):

~/bazarghar-ai$ python loops_numba.py
numba 0.68.1 | NumPy 2.5.4
numba first call (compile+run) 541 ms
numba later calls median       4.51 ms
python loop median             261 ms
same result: True [99 88 79 73 67 55]

প্রথম call-এ ৫৪১ ms, যার প্রায় সবটাই compile; এটিই warm-up-এর সবচেয়ে স্পষ্ট উদাহরণ। পরের call-গুলো প্রায় ৪.৫ ms, Python loop-এর চেয়ে এই run-এ প্রায় ৫৮ গুণ দ্রুত, আর ফল হুবহু এক। কিন্তু দাম আছে: Numba একটি বাড়তি dependency, নতুন Python version এলে কিছুদিন পিছিয়ে থাকতে পারে, সব Python জিনিস (dict-ভরা object, pandas DataFrame) বোঝে না, আর error-এর বার্তা কঠিন। এটি এই পাতার ব্রাউজারের Python-এ নেই, তাই এখানে চালানো যাবে না। আরেকটি প্রতিষ্ঠিত পথ Cython: Python-এর মতো দেখতে একটি ভাষা, যাতে type লিখে দিলে আগেভাগেই C-তে অনুবাদ করে compile করা যায়। NumPy, SciPy আর scikit-learn-এর অনেক অংশ এভাবে লেখা। এটি একটি build ধাপ চায়, তাই সাধারণত library লেখকদের হাতিয়ার; এই পর্বে শুধু নামটি জেনে রাখুন।

৮ · ধীর জায়গা খুঁজে বের করা — cProfile, আর memmap-এর কথা

ডাক্তার পুরো শরীরের অপারেশন করেন না; আগে পরীক্ষা করে দেখেন সমস্যা কোথায়। ধীর কোডেও আগে profiling: প্রোগ্রাম চালিয়ে মাপা, কোন function কতবার ডাকা হলো আর কত সময় নিল। প্রায়ই দেখা যায়, পুরো সময়ের বেশির ভাগ যাচ্ছে এক-দুটি জায়গায়, আর অন্য জায়গা নিখুঁত করে কোনো লাভ নেই। Python-এর সঙ্গে আসে cProfile. বাজারঘরের ২ লাখ অর্ডার থেকে ৬৪টি জেলার গড় বের করার একটি ধীর function নিয়ে লেখকের ল্যাপটপে একটি সত্যিকারের run:

def to_taka(p):
    return round(p / 100, 2)

def district_means_slow(district, paisa):
    out = {}
    for d in range(64):
        vals = [to_taka(p) for p, dd in zip(paisa, district) if dd == d]
        out[d] = sum(vals) / len(vals)
    return out
~/bazarghar-ai$ python profile_it.py
         400130 function calls in 1.515 seconds

   Ordered by: internal time
   List reduced from 6 to 5 due to restriction <5>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    1.029    1.029    1.515    1.515 slow_report.py:11(district_means_slow)
   200000    0.441    0.000    0.441    0.000 {built-in method builtins.round}
   200000    0.040    0.000    0.481    0.000 slow_report.py:8(to_taka)
       64    0.005    0.000    0.005    0.000 {built-in method builtins.sum}
       64    0.000    0.000    0.000    0.000 {built-in method builtins.len}

slow 1450 ms | fast 0.82 ms (no profiler)

পড়ার নিয়ম: ncalls কতবার ডাকা হলো, tottime শুধু ওই function-এর নিজের ভেতরের সময়, cumtime তার ভেতর থেকে ডাকা সবকিছুসহ। তিনটি জিনিস বেরিয়ে আসে। (১) district_means_slow-এর নিজের ১.০৩ সেকেন্ড: ৬৪টি জেলার প্রতিটির জন্য পুরো ২ লাখ সারি নতুন করে হাঁটা, মানে ১ কোটি ২৮ লাখ তুলনা। (২) round ২ লাখবার, ০.৪৪ সেকেন্ড: প্রতিটি সংখ্যাকে আলাদা করে গোল করা, অথচ গড়ের জন্য তা দরকারই নেই। (৩) Python 3.12 থেকে list comprehension আলাদা function হিসেবে দেখায় না, তাই তার সময় বাইরের function-এর tottime-এ মিশে আছে। সমাধানটি NumPy-র একটি function: np.bincount(district, weights=paisa) এক পাসে প্রতিটি জেলার মোট দেয়, আর np.bincount(district) দেয় গোনা; ভাগ করলেই গড়। শেষ লাইনে profiler ছাড়া মাপ: প্রায় ১৪৫০ ms থেকে ০.৮২ ms. খেয়াল করুন, profiler নিজেও কোডকে কিছুটা ধীর করে, তাই চূড়ান্ত তুলনা সবসময় profiler ছাড়া।

ডেটা যখন RAM-এর চেয়ে বড় — np.memmap. Memory mapping-এ অপারেটিং সিস্টেম একটি ডিস্কের ফাইলকে এমনভাবে দেখায় যেন সেটি মেমরিতে আছে; যে অংশ ছোঁয়া হয়, শুধু সেটুকুই ডিস্ক থেকে আনা হয়। np.memmap("sales.f32", dtype=np.float32, mode="r", shape=(rows, cols)) একটি array-এর মতো জিনিস দেয়, যার ওপর slicing আর .sum() চলে। লেখকের ল্যাপটপে ১০ লাখ × ৮-এর একটি ৩২ MB ফাইলের একটি কলাম যোগ করতে tracemalloc-এর হিসাবে চূড়া ছিল মাত্র ০.১৩ MB (একটি run); ফাইলের পাতাগুলো অপারেটিং সিস্টেমের cache-এ যায়, Python-এর হিসাবে নয়। দুটি সতর্কতা: ফাইলে কোনো header নেই, তাই dtype আর shape নিজেকেই মনে রাখতে হয় (np.save-এর .npy ফাইল এগুলো লিখে রাখে, আর np.load(..., mmap_mode="r") একই সুবিধা দেয়); আর C order-এর ফাইলে একটি কলাম পড়া মানে ডিস্কের সব জায়গায় লাফানো, ঠিক অংশ ৩-এর মতো, শুধু অনেক বেশি দামে। বড় টেবিলের জন্য Parquet আর chunk করে পড়া আসবে পর্ব ২৭-এ।

৯ · চেকলিস্ট — NumPy কোড ধীর বা মেমরি-খরুচে মনে হলে

প্রশ্নকীভাবে দেখবেনসাধারণ সমাধান
সত্যিই ধীর? কোথায়?warm-up, কয়েকবার, median; তারপর cProfileশুধু সবচেয়ে বড় খরচের জায়গায় হাত দিন
Python loop-এ সংখ্যার কাজ?profile-এ লাখো ছোট callপুরো array-এর ufunc, bincount, cumsum, accumulate
মেমরির ক্রম উল্টো?.strides, .flagsNumPy-কে axis দিন, বা একবার ascontiguousarray
dtype বেশি বড় বা বেশি ছোট?.dtype, .nbytes, np.iinfofloat32 যেখানে চলে; যোগফলে float64; সীমা দেখে int
অনেক temporary?tracemalloc-এর চূড়া+=, out=, আগে বানানো buffer
লুকানো copy?np.shares_memory, .baseslice-এ view; বড় array-এর ছোট অংশ রাখতে .copy()
সত্যিই ধারাবাহিক?আগের ফলে সিদ্ধান্ত নির্ভর করে কি নাloop রাখুন; বড় হলে Numba
RAM-এ ধরে না?ডেটার আকার বনাম RAMnp.memmap, mmap_mode, chunk

আর প্রতিটি বদলের পর দুটি কাজ: ফল আগের মতোই আছে কি না মেলানো, আর আবার মাপা। যতটুকু দ্রুত দরকার, ততটুকু হলেই থামুন; পড়তে সহজ কোডের মূল্য গতির চেয়ে কম নয়।

আরও গভীরে। NumPy-র ndarray আসলে একটি মেমরির টুকরো আর তার ব্যাখ্যা (dtype, shape, strides, offset); এই ধারণা বর্ণনা করেছেন Stéfan van der Walt-সহ লেখকেরা ২০১১ সালের “The NumPy Array: A Structure for Efficient Numerical Computation” প্রবন্ধে, আর ২০২০ সালে Nature-এ Charles Harris-সহ লেখকদের “Array programming with NumPy”. Cache আর মেমরির গতির ব্যবধান নিয়ে Ulrich Drepper-এর ২০০৭ সালের দীর্ঘ লেখা “What Every Programmer Should Know About Memory” আজও বহুল পড়া। আর strides দিয়ে নতুন মেমরি ছাড়াই “sliding window” বানানো যায় (np.lib.stride_tricks.sliding_window_view), কিন্তু ভুল strides দিলে NumPy মেমরির বাইরের আবর্জনা পড়তে পারে, তাই সাবধানে।

পরীক্ষা ও ইন্টারভিউতে (বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এ ধরনের প্রশ্ন সাধারণ):

  • shape (1000, 500)-এর C-order float64 array-এর strides কত? (500 × 8, 8) = (4000, 8) byte; Fortran order-এ (8, 8000).
  • C order আর Fortran order-এর পার্থক্য? C-তে শেষ index সবচেয়ে দ্রুত বদলায় (সারি টানা); Fortran-এ প্রথম index (কলাম টানা)। NumPy-র default C.
  • float32 কেন ব্যবহার করবেন, আর কখন নয়? অর্ধেক মেমরি আর প্রায়ই দ্রুত; কিন্তু প্রায় ৭ অঙ্ক নির্ভুল, তাই বড় যোগফল আর টাকায় float64 বা পূর্ণসংখ্যা।
  • a = a + b আর a += b-এর পার্থক্য? প্রথমটি নতুন array বানিয়ে নাম সরায়; দ্বিতীয়টি a-এর নিজের মেমরিতে লেখে, তাই a-এর অন্য view-ও বদলায়, আর dtype একই থাকে।
  • কোন loop vectorize করা যায় না? যেখানে প্রতিটি ধাপের সিদ্ধান্ত আগের ধাপের ফলের ওপর নির্ভর করে; শুধু যোগ বা সর্বোচ্চ জমলে cumsum বা ufunc.accumulate চলে।

১০ · AI-এর যুগে — AI সহকারীর “optimization” যাচাই

“এই NumPy কোড দ্রুত করে দাও” লিখলে একটি AI সহকারী আত্মবিশ্বাসী উত্তর দেবে, প্রায়ই একটি গুণফলসহ (“১০০ গুণ দ্রুত হবে”)। সেই সংখ্যা সে মাপেনি; আপনার ডেটা আর যন্ত্রও সে দেখেনি। এই বিষয়ে AI সহকারীরা প্রায়ই যা করে:

  • না-মাপা গতির দাবি করে, আর মাপলেও একবারের সময় বা warm-up-সহ প্রথম run দেখায়; Numba-র compile-সময় গোপন থাকে।
  • মেমরি বাঁচাতে float16 বা float32 সুপারিশ করে, কিন্তু যোগফলের ভুল বা integer overflow-এর কথা বলে না; astype(np.int32) বসিয়ে দেয় বড় টাকার কলামে।
  • পুরোনো বা বানানো API লেখে: NumPy 2-এ সরানো np.float, np.int বা np.product, অথবা এমন parameter যা ওই function-এ নেই।
  • Function-এর input-কে in-place বদলে দেয় (লুকানো side effect), বা “vectorize” করতে np.vectorize ব্যবহার করে, যা নথি অনুযায়ী মূলত সুবিধার জন্য, গতির জন্য নয়; ভেতরে এটি loop-ই চালায়।
  • ধারাবাহিক নিয়মকে (threshold-এ রিসেট) cumsum দিয়ে “vectorize” করে, যা দেখতে ঠিক কিন্তু ভুল ফল দেয়; আর warning চাপা দিতে np.seterr(all="ignore") বসায়।

প্রম্পটে ডেটার আকার, dtype আর যাচাইয়ের শর্ত নিজেই দিন। আসল ডেটা নয়, বানানো নমুনা দিন।

আমি Python 3.14 আর NumPy 2.x ব্যবহার করছি। আমার কাছে 5,000,000 সারির দুটি float64 array আছে (sales, price) আর একটি int64 array (district, 0 থেকে 63). নিচের ধীর function-টি দ্রুত করো: [এখানে আমার ছোট, বানানো উদাহরণের কোড] শর্ত: ১. ফল আগের function-এর সঙ্গে np.allclose দিয়ে মিলবে, এমন একটি ছোট test দাও। ২. input array কখনো in-place বদলাবে না। ৩. dtype বদলালে কেন, আর কোথায় নির্ভুলতা বা overflow-এর ঝুঁকি, স্পষ্ট বলো। ৪. গতির কোনো সংখ্যা বানিয়ে বলবে না; বরং warm-up আর ৫ বারের median দিয়ে মাপার কোড দাও। ৫. NumPy 2-এ সরানো কোনো alias (np.float, np.int, np.product) ব্যবহার করবে না, আর কোনো function বা parameter নিয়ে নিশ্চিত না হলে বলো।

AI-এর “দ্রুত” কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১CPU এত দ্রুত হয়েও কেন মেমরির ক্রম এত গুরুত্বপূর্ণ? “memory wall” কথাটির মানে কী?

কয়েক দশক ধরে প্রসেসরের গতি যে হারে বেড়েছে, মূল মেমরির (DRAM) সাড়া দেওয়ার গতি সে হারে বাড়েনি। ১৯৯৫ সালে William Wulf আর Sally McKee “Hitting the Memory Wall: Implications of the Obvious” নামে একটি ছোট প্রবন্ধে যুক্তি দেন, এই ব্যবধান বাড়তে থাকলে একসময় প্রোগ্রামের গতি প্রায় পুরোপুরি মেমরির গতিতে বাঁধা পড়বে, CPU যত দ্রুতই হোক। এই ধারণাটিই “memory wall” নামে পরিচিত। তাঁদের ভবিষ্যদ্বাণী হুবহু মেলেনি, কারণ প্রসেসর নির্মাতারা বহুস্তরের cache, prefetching (আগাম আন্দাজে ডেটা আনা) আর বেশি bandwidth দিয়ে দেয়ালটিকে পিছিয়ে দিয়েছেন। কিন্তু মূল সত্যটি রয়ে গেছে: RAM থেকে একটি নতুন cache line আনতে যে সময় লাগে, তাতে CPU অনেকগুলো গণনা করে ফেলতে পারে।

তাই NumPy-র মতো কাজে, যেখানে প্রতিটি সংখ্যার ওপর গণনা খুব সামান্য (একটি যোগ বা গুণ), আসল খরচ হয়ে দাঁড়ায় ডেটা আনা। অংশ ৩-এর মাপে ঠিক এটিই দেখা গেছে: একই সংখ্যক যোগ, কিন্তু কলাম ধরে হাঁটায় প্রায় ৯ গুণ সময়, কারণ প্রতিটি ৮ byte-এর জন্য ৬৪ byte আনতে হয়েছে আর prefetcher বড় লাফ আন্দাজ করতে পারেনি। অংশ ৪-এ float32 যে প্রায় দ্বিগুণ দ্রুত, তার কারণও গণনা নয়, অর্ধেক byte আনা।

এর একটি বড় ফল আছে AI-তে। বড় ভাষা-মডেলের inference-এ প্রতিটি নতুন token-এর জন্য মডেলের সব weight মেমরি থেকে পড়তে হয়, তাই অনেক ক্ষেত্রে গতির সীমা ঠিক করে মেমরির bandwidth, গণনার ক্ষমতা নয়। এ কারণেই quantization (weight-কে ৮ বা ৪ bit-এ রাখা) এত জনপ্রিয়: কম byte মানে কম আনা-নেওয়া। Roofline model নামে একটি জনপ্রিয় চিত্র (Williams, Waterman আর Patterson, ২০০৯) দেখায় একটি কাজ গণনায় আটকে আছে, নাকি মেমরিতে।

মূল উপলব্ধি: আধুনিক যন্ত্রে সংখ্যার হিসাব প্রায়ই সস্তা, ডেটা আনা দামি; তাই কম byte আর মেমরির ক্রম মেনে হাঁটাই অনেক সময় সবচেয়ে বড় optimization.

প্র ০২NumPy কেন C order আর Fortran order দুটিই সমর্থন করে? Fortran-এর সেই পুরোনো সিদ্ধান্ত আজও কেন প্রাসঙ্গিক?

Fortran এসেছিল ১৯৫০-এর দশকের শেষে, IBM-এ John Backus-এর নেতৃত্বে, আর প্রথম দিকের সবচেয়ে সফল উচ্চস্তরের ভাষাগুলোর একটি। এটি বিজ্ঞান আর প্রকৌশলের হিসাবের জন্য তৈরি, আর এর array কলাম ধরে মেমরিতে বসে। পরের কয়েক দশকে বিজ্ঞানের সবচেয়ে গুরুত্বপূর্ণ সংখ্যার library-গুলো এই ভাষায় লেখা হয়: matrix-এর মৌলিক কাজের মানদণ্ড BLAS (প্রথম সংস্করণ ১৯৭০-এর দশকের শেষে), আর linear algebra-র LAPACK (১৯৯০-এর দশকের শুরুতে)। এগুলো কলাম-ক্রমে array আশা করে। MATLAB, R আর Julia-ও কলাম-ক্রমে চলে। অন্যদিকে C (১৯৭০-এর দশক) আর তার উত্তরসূরিরা সারি-ক্রম বেছে নেয়।

NumPy দুই জগতের মাঝে দাঁড়িয়ে। Python-এর নিচে C, তাই default C order. কিন্তু np.linalg-এর অনেক কাজ ভেতরে LAPACK ডাকে। Strides-এর ধারণার কারণে NumPy একটি array-কে কোনো copy ছাড়াই দুই ক্রমেই “দেখতে” পারে: C-order array-এর transpose হলো Fortran-order. তাই একটি C-order matrix-কে LAPACK-এ পাঠানোর আগে অনেক সময় শুধু transpose-এর ব্যাখ্যা বদলালেই চলে। SciPy-র কিছু function আবার স্পষ্টভাবে Fortran-order array পেলে copy এড়ায়, যা তাদের নথিতে লেখা থাকে।

বাস্তবে এর মানে: অন্য ভাষা বা library-র সঙ্গে ডেটা আদান-প্রদানের সময় ক্রম মেলাতে হয়। MATLAB-এর একটি ফাইল পড়ে সারি-কলাম গুলিয়ে যাওয়া, বা ছবির library থেকে আসা array-কে transpose করে C-কোডে দিলে আবার copy হওয়া, এগুলো সাধারণ অভিজ্ঞতা। np.asfortranarray, np.ascontiguousarray আর order= parameter এই সেতুর হাতিয়ার। কোনো ক্রমই “ভালো” নয়; যে ক্রমে আপনি হাঁটবেন, সেটিই ভালো।

মূল উপলব্ধি: মেমরির ক্রম একটি ঐতিহাসিক রীতি, প্রাকৃতিক নিয়ম নয়; strides সেই দুই রীতির মধ্যে বিনা খরচের সেতু, কিন্তু অন্য library-তে পাঠানোর সময় লুকানো copy হতে পারে।

প্র ০৩Deep learning float32, এমনকি float16-এ চলে; অথচ এই পর্ব বলছে float32-এ যোগফলে ভুল জমে। এই দুটি কথা একসঙ্গে সত্য হয় কীভাবে?

দুটি কথাই সত্য, কারণ কোথায় কম নির্ভুলতা চলে আর কোথায় চলে না, তা আলাদা। নিউরাল নেটওয়ার্কের weight আর activation নিজেরাই আনুমানিক; প্রশিক্ষণ নিজেই random শব্দে ভরা (mini-batch, dropout), তাই সপ্তম অঙ্কের ভুল সেখানে প্রায় অদৃশ্য। কিন্তু অনেক ছোট সংখ্যার যোগফল, যেমন gradient জমানো বা loss-এর গড়, ঠিক সেই জায়গা যেখানে অংশ ৪-এর stepper-এর মতো ভুল জমে, বা ছোট মান হারিয়ে যায়।

২০১৭ সালে Paulius Micikevicius-সহ NVIDIA আর Baidu-র গবেষকদের “Mixed Precision Training” প্রবন্ধ (ICLR ২০১৮-এ প্রকাশিত) এই সমস্যার একটি প্রভাবশালী সমাধান দেয়: গণনার বেশির ভাগ float16-এ, কিন্তু weight-এর একটি “master copy” float32-এ রাখা, আর gradient খুব ছোট হয়ে শূন্যে হারিয়ে না যায় তাই loss-কে একটি বড় সংখ্যা দিয়ে গুণ করা (loss scaling). পরে Google-এর bfloat16 format জনপ্রিয় হয়, যা float32-এর সমান পরিসর রাখে কিন্তু কম অঙ্ক; এতে loss scaling-এর দরকার কমে। হার্ডওয়্যারও এই ধারণায় গড়া: কম bit-এ গুণ, কিন্তু বেশি bit-এ যোগ জমানো।

যোগের ভুল কমানোর গাণিতিক কৌশলও পুরোনো। William Kahan ১৯৬৫ সালে compensated summation প্রকাশ করেন, যা প্রতিটি যোগে হারানো অংশ আলাদা করে রেখে পরে ফেরত দেয়। NumPy-র pairwise summation আরেকটি কৌশল, আর Python-এর math.fsum নির্ভুলভাবে গোল করা যোগফল দেয়। Integer-এর দিকে ঝুঁকি আরও নিষ্ঠুর: ১৯৯৬ সালে Ariane 5 রকেটের প্রথম উড্ডয়ন ব্যর্থ হয়; তদন্ত প্রতিবেদন অনুযায়ী একটি ৬৪-বিট float-কে ১৬-বিট পূর্ণসংখ্যায় রূপান্তরের সময় মান সীমা ছাড়িয়ে যায়, আর সেই error-ই ধারাবাহিক ব্যর্থতার সূচনা করে।

মূল উপলব্ধি: কম নির্ভুলতা একটি সচেতন বিনিময়: গুণ আর সংরক্ষণে ছোট dtype চলে, কিন্তু যোগফল জমানো আর সীমার কাছের পূর্ণসংখ্যায় বড় dtype বা বিশেষ কৌশল লাগে।

প্র ০৪“Premature optimization is the root of all evil” — Knuth আসলে কী বলেছিলেন, আর profiling কেন সেই কথার বাস্তব রূপ?

Donald Knuth-এর এই বিখ্যাত বাক্যটি আছে ১৯৭৪ সালের “Structured Programming with go to Statements” প্রবন্ধে। কিন্তু প্রায়ই অর্ধেক উদ্ধৃত হয়। পুরো প্রসঙ্গে তিনি বলেন, প্রোগ্রামাররা প্রোগ্রামের অপ্রয়োজনীয় অংশের গতি নিয়ে ভেবে অনেক সময় নষ্ট করেন; ছোটখাটো দক্ষতার কথা প্রায় ৯৭% সময় ভুলে যাওয়া উচিত। তারপরই তিনি যোগ করেন, বাকি গুরুত্বপূর্ণ ৩% সুযোগ ছেড়ে দেওয়াও উচিত নয়, আর সেই অংশ চেনার জন্য মাপার যন্ত্র ব্যবহার করা উচিত, অনুমান নয়। অর্থাৎ বাক্যটি optimization-বিরোধী নয়; এটি “না মেপে optimization”-বিরোধী।

অংশ ৮-এর cProfile-এর ফল ঠিক এই কথাটি দেখায়। কেউ অনুমানে হয়তো to_taka-কে দ্রুত করতে বসতেন, কারণ সেটি ২ লাখবার ডাকা হয়। কিন্তু profile বলছে আসল খরচ অ্যালগরিদমে: প্রতিটি জেলার জন্য পুরো ডেটা নতুন করে হাঁটা। সেখানে হাত দিলে এক হাজার গুণের বেশি লাভ; to_taka নিখুঁত করলে বড়জোর এক-তৃতীয়াংশ।

একটি সম্পর্কিত গাণিতিক সত্য হলো Gene Amdahl-এর ১৯৬৭ সালের যুক্তি, যা Amdahl's law নামে পরিচিত: প্রোগ্রামের যে অংশ আপনি দ্রুত করছেন, মোট সময়ে তার ভাগ যত, মোট লাভ তার বেশি হতে পারে না। কোনো অংশ মোট সময়ের ১০% হলে সেটিকে অসীম দ্রুত করলেও পুরো প্রোগ্রাম বড়জোর প্রায় ১১% দ্রুত হবে। তাই প্রথম প্রশ্ন সবসময়: “সময় কোথায় যাচ্ছে?” Profiling ছাড়া এর উত্তর অভিজ্ঞ প্রোগ্রামারদেরও প্রায়ই ভুল হয়। আর ডেটার কাজে আরেকটি বাস্তবতা: অনেক সময় সবচেয়ে বড় খরচ ফাইল পড়া বা নেটওয়ার্ক, NumPy-র হিসাব নয়।

মূল উপলব্ধি: অনুমান নয়, মাপ: আগে profile করে সবচেয়ে বড় খরচ খুঁজুন, সেখানেই কাজ করুন, তারপর আবার মাপুন আর যথেষ্ট হলে থামুন।

প্র ০৫Numba, Cython, PyPy আর Python-এর নিজের JIT — Python-কে দ্রুত করার এত চেষ্টা কেন, আর কোনটি কখন?

CPython, মানে সাধারণ Python, প্রতিটি লাইন একটি interpreter দিয়ে চালায়, যা প্রতিটি ধাপে type দেখে। এটি নমনীয়, কিন্তু ধীর। এই ধীরতা কাটানোর চেষ্টার দীর্ঘ ইতিহাস আছে, আর প্রতিটি পথ ভিন্ন বিনিময় বেছে নিয়েছে। Cython ২০০৭ সালের দিকে Pyrex নামের পুরোনো প্রকল্প থেকে আলাদা হয়ে শুরু হয়। এটি আগেভাগে C-তে অনুবাদ করে compile করে; type লিখলে C-এর কাছাকাছি গতি দেয়। NumPy, SciPy, scikit-learn আর pandas-এর অনেক ভেতরের অংশ Cython-এ লেখা। দাম: একটি build ধাপ আর C compiler লাগে, আর কোড আর খাঁটি Python থাকে না।

Numba শুরু হয় ২০১২ সালের দিকে, Travis Oliphant-এর সহ-প্রতিষ্ঠিত Continuum Analytics-এ (পরে Anaconda নামে পরিচিত)। এটি LLVM দিয়ে চালানোর মুহূর্তে compile করে, শুধু NumPy আর সংখ্যার কোডের জন্য। কোড Python-ই থাকে, একটি decorator যোগ করলেই হয়; কিন্তু সব Python বোঝে না, আর প্রথম call-এ compile-এর দেরি হয় (অংশ ৭-এ দেখেছেন ৫৪১ ms). PyPy পুরো Python-এর জন্যই একটি বিকল্প interpreter, যার নিজের JIT আছে; খাঁটি Python কোডে অনেক দ্রুত হতে পারে, কিন্তু NumPy-র মতো C extension-এর সঙ্গে সামঞ্জস্য ঐতিহাসিকভাবে কঠিন ছিল। আর CPython নিজেও বদলাচ্ছে: Python 3.11 থেকে interpreter উল্লেখযোগ্যভাবে দ্রুত হয়েছে, আর 3.13-এ PEP 744-এর অধীনে একটি পরীক্ষামূলক JIT যোগ হয়েছে, যা default-এ বন্ধ থাকে। এর বাস্তব লাভ কতটা হবে, তা লেখার সময়ও আলোচনার বিষয়।

কখন কোনটি? প্রথমে সবসময় NumPy-র ভাষায় ভাবা: পুরো array-এর কাজ, bincount, cumsum, accumulate. সত্যিই ধারাবাহিক loop থেকে গেলে আর profile বলছে সেটিই খরচ, তখন Numba সবচেয়ে কম ঝামেলার। Library লিখলে বা C কোডের সঙ্গে জুড়তে হলে Cython. আর পুরো প্রকল্পটাই খাঁটি Python-এর ব্যবসায়িক যুক্তি হলে PyPy বা নতুন CPython-এর গতি বিবেচনা করা যায়। সমাজে এ নিয়ে মতভেদ আছে; কেউ Rust-এ লেখা extension-কে ভবিষ্যৎ মনে করেন, যেমন Polars-এর ক্ষেত্রে।

মূল উপলব্ধি: প্রতিটি গতির টুল কিছু নমনীয়তা বা সরলতা বিনিময় করে; আগে vectorize, তারপর মাপ, তারপর সবচেয়ে কম ঝামেলার টুল।

অনুশীলন

প্রথমটি হিসাবের, দ্বিতীয়টি আউটপুট অনুমানের, তৃতীয়টি নকশা ও সমালোচনার, শেষটি কোড লেখার।

  1. হিসাব: b = np.zeros((100, 50), dtype=np.float32). (ক) b.nbytes আর b.strides কত? (খ) np.asfortranarray(b).strides কত? (গ) b[:, ::5].strides কত, আর এটি কি C-contiguous? (ঘ) ১ কোটি float64-এর তিনটি array x, y, z-এর জন্য r = x * y + z লিখলে ফলসহ বাড়তি মেমরির চূড়া কত, আর in-place-এ কত?

    (ক) 100 × 50 × 4 = 20,000 byte. Strides: পরের সারিতে 50 × 4 = 200 byte, পরের কলামে 4, তাই (200, 4). (খ) Fortran order-এ পরের সারি পাশের ঘর (4 byte), পরের কলাম 100 × 4 = 400 byte: (4, 400). (গ) সারির লাফ একই 200, কলামে প্রতি ৫টিতে একটি: 5 × 4 = 20; (200, 20), আর এটি C-contiguous নয় (ফাঁক আছে)। লেখকের যন্ত্রে সত্যিকারের run: (200, 4) (4, 400) (200, 20) 20000.

    (ঘ) প্রতিটি array 107 × 8 = 80 MB. x * y একটি temporary (80 MB), তারপর + z আরেকটি নতুন array (80 MB): চূড়া 160 MB বাড়তি। r = x * y; r += z লিখলে চূড়া 80 MB. আর আগে বানানো buffer-এ np.multiply(x, y, out=buf); buf += z দিলে প্রতি call-এ নতুন মেমরি শূন্য।

  2. আউটপুট অনুমান করুন: চালানোর আগে প্রতিটি লাইনের আউটপুট লিখুন (৬৪-বিট desktop NumPy 2 ধরে)।
    import numpy as np
    a = np.arange(6, dtype=np.int64).reshape(2, 3)
    print(a.strides, a.T.strides)
    print(a.T.flags.c_contiguous, a[:, 1].flags.c_contiguous)
    x = np.array([250, 10], dtype=np.uint8)
    print(x + np.uint8(10))
    print(np.float32(16777216) + np.float32(1))

    লেখকের যন্ত্রে সত্যিকারের run (Python 3.14.4, NumPy 2.5.4):

    (24, 8) (8, 24)
    False False
    [ 4 20]
    1.6777216e+07

    প্রথম লাইন: এক সারি ৩টি × ৮ byte = 24; transpose শুধু strides উল্টায়। দ্বিতীয়: a.T F-contiguous, C নয়; a[:, 1]-এর একমাত্র stride 24, যা ৮-এর বেশি, তাই টানা নয়। তৃতীয়: uint8-এর সীমা 255; 250 + 10 = 260, যা 256 দিয়ে ঘুরে 4 হয়, কোনো error বা সতর্কবার্তা ছাড়া। চতুর্থ: 16,777,216 = 224, যেখানে পাশাপাশি দুটি float32-এর দূরত্ব 2, তাই 1 যোগ হারিয়ে যায় আর ফল একই থাকে।

  3. নকশা ও সমালোচনা: একটি AI সহকারী বাজারঘরের দৈনিক বিক্রির রিপোর্ট “দ্রুত” করতে নিচের কোড দিয়েছে আর বলেছে “এটি ৫০ গুণ দ্রুত”। অন্তত পাঁচটি সমস্যা চিহ্নিত করুন।
    def fast_report(paisa, qty):
        paisa = paisa.astype(np.int32)
        qty = qty.astype(np.float16)
        paisa *= 2                        # দুই কপি হিসাব
        total = np.cumsum(paisa * qty)[-1]
        np.seterr(all="ignore")
        return np.float(total)
    • না-মাপা দাবি: “৫০ গুণ” কোনো মাপ ছাড়া; warm-up আর কয়েকবারের median দিয়ে নিজে মাপতে হবে, আর পুরোনো কোডের সঙ্গে ফল মেলাতে হবে।
    • overflow-এর ঝুঁকি: int32-এর সীমা প্রায় ২১৪ কোটি পয়সা, মানে প্রায় ২ কোটি ১৫ লাখ টাকা; বড় অর্ডারের গুণফল বা যোগফল তা ছাড়াতে পারে, চুপচাপ।
    • float16: প্রায় ৩ অঙ্ক নির্ভুল, সর্বোচ্চ মান 65504; পরিমাণ আর টাকার গুণফল এখানে সম্পূর্ণ ভুল হবে।
    • যোগফলে cumsum: শুধু শেষ মান দরকার, তবু পুরো চলমান array বানানো হচ্ছে (বাড়তি মেমরি), আর এটি pairwise summation পায় না। np.sum যথেষ্ট, টাকায় পূর্ণসংখ্যায়।
    • warning চাপা আর পুরোনো API: np.seterr(all="ignore") সব float-সতর্কবার্তা লুকায়, আর তা পুরো প্রোগ্রামের জন্য; np.float NumPy 1.24 থেকে সরানো, NumPy 2-এ AttributeError.
    • লুকানো ঝুঁকি: astype copy দেয় বলে এখানে input বাঁচে, কিন্তু কেউ astype সরিয়ে দিলে paisa *= 2 ডাকার জায়গার array বদলে দেবে; আর “দুই কপি” নিয়ম কোডে লুকানো, নামে নেই।

    ভালো সংস্করণ: dtype না বদলে (int64 পয়সা, int64 পরিমাণ) total = int((paisa * qty).sum()) * 2, সঙ্গে আগের সংস্করণের সঙ্গে মেলানো একটি test আর মাপার কোড।

  4. কোড লিখুন: বাজারঘরের অর্ডারগুলো দিন অনুযায়ী সাজানো: day = [1, 1, 1, 2, 2, 3, 3, 3], amt = [300, 120, 50, 700, 80, 40, 60, 200]. Loop ছাড়া প্রতিটি অর্ডারের পর “ওই দিনের এ পর্যন্ত মোট” বের করুন (প্রতিদিন শূন্য থেকে শুরু)।
    day = np.array([1, 1, 1, 2, 2, 3, 3, 3])
    amt = np.array([300, 120, 50, 700, 80, 40, 60, 200])
    c = np.cumsum(amt)
    new = np.r_[True, day[1:] != day[:-1]]          # নতুন দিন শুরু
    start = np.maximum.accumulate(np.where(new, np.arange(amt.size), 0))
    before = np.where(start > 0, c[start - 1], 0)
    print(c - before)

    লেখকের যন্ত্রে সত্যিকারের run: [300 420 470 700 780 40 100 300]. হাতে মিলিয়ে দেখুন: দিন ১-এ 300, 420, 470; দিন ২-এ 700, 780; দিন ৩-এ 40, 100, 300. কৌশলটি অংশ ৭-এর মতোই: মোট চলমান যোগফল থেকে দিনের শুরুর আগের যোগফল বাদ। দিন সাজানো না থাকলে আগে np.argsort(day, kind="stable") দিয়ে সাজাতে হবে। pandas-এ একই কাজ এক লাইনে, groupby("day")["amt"].cumsum(), যা আসবে পর্ব ২৪-এ।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি ধীর NumPy function দ্রুত করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ⏱️ আসল আকারের ডেটায় warm-up দিয়ে কয়েকবার মেপে শুরুর সময় লেখা
  2. 🔍 cProfile দিয়ে দেখা কোন জায়গায় সবচেয়ে বেশি সময় যায়
  3. 🛠️ শুধু সেই জায়গা বদলানো: vectorize, dtype, in-place বা Numba
  4. ✅ পুরোনো আর নতুন ফল np.allclose দিয়ে মেলানো
  5. 📏 আবার একইভাবে মেপে তুলনা করা
  6. 🛑 লক্ষ্য পূরণ হলে থামা, না হলে পরের বড় খরচে যাওয়া

shape (1000, 500)-এর একটি C-order float64 NumPy array-এর strides কত?

  • (8, 8000)
  • (4000, 8)
  • (500, 1)
  • (8, 4000)

একটি বড় C-order 2D array a থেকে col = a[:, 5] নেওয়া হলো। কোন কথাটি সঠিক?

  • এটি একটি copy, তাই col বদলালে a বদলায় না
  • এটি একটি view, আর C-contiguous
  • এটি একটি view, কিন্তু non-contiguous
  • এর কোনো strides নেই, কারণ এটি 1D

১০ লাখটি 0.1 একটি float32 array-এ রাখা। np.cumsum(v)[-1] দিল প্রায় 100958, আর v.sum() দিল প্রায় 100000.01. পার্থক্যের প্রধান কারণ কী?

  • np.sum pairwise summation ব্যবহার করে, cumsum একটির পর একটি যোগ করে
  • np.sum ভেতরে সবসময় float64 ব্যবহার করে
  • cumsum-এর ফল random
  • np.sum কিছু উপাদান বাদ দেয়

x = np.array([200], dtype=np.uint8); print(x + np.uint8(100)) — আউটপুট কী?

  • [300]
  • [44]
  • OverflowError
  • [255]

গুদামের দৈনিক স্টক হিসাবের কোন নিয়মটি np.cumsum-জাতীয় কৌশলে সরাসরি vectorize করা সবচেয়ে কঠিন?

  • স্টক = শুরুর ১০০ থেকে প্রতিদিনের বিক্রি বাদ
  • প্রতিদিন জানা পরিমাণ চালান আসে আর বিক্রি হয়
  • প্রতি শুক্রবার স্টক আবার ১০০ হয়
  • স্টক ২০-এর নিচে নামলেই সঙ্গে সঙ্গে ১০০-তে ভরা হয়

দুটি NumPy function-এর গতি তুলনা করতে কোন পদ্ধতি সবচেয়ে নির্ভরযোগ্য?

  • প্রতিটি একবার চালিয়ে time.time() দিয়ে সময় নেওয়া
  • ছোট ১০টি সংখ্যার array-এ একবার করে চালানো
  • একই আসল আকারের ডেটায় warm-up-এর পর কয়েকবার perf_counter বা timeit দিয়ে মেপে median বা min নেওয়া, আর ফল মেলানো
  • একটি AI সহকারীকে জিজ্ঞেস করা কোনটি দ্রুত

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. আগে মাপুন: perf_counter বা timeit, warm-up, কয়েকবার, median বা min, একই ফল; আর প্রতিটি সংখ্যা একটি যন্ত্রের একটি run মাত্র।
  2. Strides মেমরির মানচিত্র: C order-এ সারি টানা; বড় array-এ নিজের loop-এ কলাম ধরে হাঁটা কয়েক গুণ ধীর হতে পারে, কিন্তু sum(axis=...)-এর মতো পুরো কাজ NumPy নিজেই ভালো ক্রমে করে।
  3. dtype একটি বিনিময়: float32 অর্ধেক মেমরি আর প্রায়ই দ্রুত, কিন্তু যোগফলে ভুল জমে; NumPy-র integer চুপচাপ overflow করে।
  4. Temporary এড়ান: +=, out=, আগে বানানো buffer; view বিনা খরচে, fancy index আর কিছু reshape লুকিয়ে copy.
  5. Loop সবসময় শত্রু নয়: ছোট ডেটা, early exit, সত্যিকারের ধারাবাহিকতা; জানা রিসেটে cumsum/accumulate, বাকিতে Numba; আর হাত দেওয়ার আগে cProfile.

পরের পর্বে: মডিউল ৪ শুরু: NumPy-র array-এর ওপর নাম দেওয়া কলাম আর সারির label বসালে পাওয়া যায় pandas-এর Series আর DataFrame. বাজারঘরের অর্ডার-টেবিল দিয়ে index কী, আর কেন তা এত শক্তিশালী।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান