NUMA Topology — যখন 'memory' একটা একক জিনিস থাকে না
NUMA Topology
একটা multi-socket সার্ভারে প্রতিটা CPU socket-এর নিজস্ব, সরাসরি সংযুক্ত memory bank আছে — নিজের bank পড়া দ্রুত, পাশের socket-এর bank পড়তে হলে একটা interconnect পার হতে হয়, যা মাপা যায় এমন বাড়তি latency যোগ করে। এই লেসনে আমরা দেখব কেন 'RAM' একটা uniform জিনিস না বড় সার্ভারে, আর কীভাবে thread/memory affinity দিয়ে সেই অসমতাকে নিজের পক্ষে ব্যবহার করা যায় — নাহলে নীরবে ৩০-৫০% throughput হারানো সহজ।
আগে এটা বুঝি
গত লেসনে আমরা একটা single socket-এর ভেতরে থাকলাম — সব core একই memory bank শেয়ার করছিল, সমস্যাটা ছিল শুধু cache line গ্রানুলারিটি। কিন্তু ধরুন আপনার সার্ভারে দুইটা CPU socket আছে (dual-socket Xeon বা EPYC — বড় প্রোডাকশন সার্ভারে এটাই স্বাভাবিক, বিরল না)। প্রতিটা socket-এর নিজস্ব CPU core, নিজস্ব cache — কিন্তু memory-র ব্যাপারে কী?
সহজাত ধারণা: “RAM তো RAM-ই, ৬৪ GB মানে ৬৪ GB, যেকোনো core থেকে যেকোনো বাইট পড়তে একই সময় লাগার কথা।” এই ধারণাটা ১৯৯০-এর দশকের একক-socket সিস্টেমে সত্য ছিল — তখনকার architecture-এর নাম UMA (Uniform Memory Access)। কিন্তু আধুনিক multi-socket সার্ভারে এটা মিথ্যা, আর মিথ্যাটা পরিমাপযোগ্য।
প্রতিটা socket-এর একটা integrated memory controller থাকে — তার নিজস্ব DRAM channel সরাসরি সেই socket-এই সংযুক্ত। এই memory-কে বলে সেই socket-এর জন্য local। অন্য socket-এর memory পড়তে হলে একটা interconnect (Intel-এ QPI/UPI, AMD-তে Infinity Fabric) পার হতে হয় — এবং এটা সবসময় ধীর, কতটা ধীর তা measurement দিয়ে ঠিক করা যায়, অনুমান দিয়ে না।
এই স্থাপত্যের নাম NUMA — Non-Uniform Memory Access। আর এই লেসনের কেন্দ্রীয় বার্তা: কোন থ্রেড কোন core-এ চলছে, আর তার ডেটা কোন socket-এর memory-তে বসে আছে — এই দুইটা প্রশ্নের উত্তর যদি না মেলে, প্রোগ্রামটা নীরবে নিজের সম্ভাব্য গতির অর্ধেকে চলতে পারে, কোনো error, কোনো warning ছাড়া।
মূল ধারণা
UMA বনাম NUMA
UMA (একক socket, বা পুরনো multi-socket-এর সরল মডেল)
┌─────────────────────────────────────────┐
│ Core 0 Core 1 Core 2 Core 3 │
│ \ | | / │
│ \_______|________|_______/ │
│ | │
│ Memory Controller │
│ | │
│ RAM │
│ (সব core-এর জন্য একই latency) │
└─────────────────────────────────────────┘
NUMA (dual-socket)
┌───────────────────────┐ Interconnect ┌───────────────────────┐
│ Node 0 (Socket 0) │◄────QPI/UPI────►│ Node 1 (Socket 1) │
│ Core 0 Core 1 │ (ধীর, remote) │ Core 4 Core 5 │
│ \ / │ │ \ / │
│ Memory Controller 0 │ │ Memory Controller 1 │
│ | │ │ | │
│ RAM (local for │ │ RAM (local for │
│ node 0, দ্রুত) │ │ node 1, দ্রুত) │
└───────────────────────┘ └───────────────────────┘
Core 0 → node-0 RAM: LOCAL, দ্রুত
Core 0 → node-1 RAM: REMOTE, interconnect পার হতে হয়, ধীরএকটা NUMA node সাধারণত একটা physical socket (কখনো একটা socket-এর ভেতরেই একাধিক node থাকতে পারে — AMD EPYC-র কিছু কনফিগারেশনে, “NPS” সেটিং দিয়ে) — মূল কথা: একটা node হলো core-দের একটা দল যাদের একটা কমন local memory controller আছে।
Local বনাম remote — বাস্তব সংখ্যা
| Local access | Remote access (এক hop) | অনুপাত | |
|---|---|---|---|
| DRAM latency (typical dual-socket Xeon) | ~৮০–১০০ ns | ~১৩০–১৮০ ns | ~১.৫–২× |
| কার্যকর bandwidth | পূর্ণ | ৩০-৫০% কম | interconnect shared/সীমিত |
সংখ্যাগুলো CPU generation আর interconnect প্রজন্ম অনুযায়ী বদলায়, কিন্তু অনুপাতটা (remote ≈ ১.৫–২× local latency) এক দশকেরও বেশি সময় ধরে বিভিন্ন হার্ডওয়্যার প্রজন্মে মোটামুটি ধারাবাহিক থেকেছে — কারণ ভৌত সীমাবদ্ধতাটা মৌলিক: interconnect পার হওয়া মানে একটা অতিরিক্ত physical hop, অতিরিক্ত protocol overhead (coherence মেসেজ, গত দুই লেসনের MESI-জাতীয় protocol-ই কিন্তু এখন socket-এর মধ্যেও চলে), অতিরিক্ত সময়।
First-touch policy — সবচেয়ে বেশি ভুল বোঝা নিয়ম
এখানে সবচেয়ে গুরুত্বপূর্ণ, প্রায়ই আশ্চর্যজনক নিয়মটা আসে।
malloc(size) কল করলে কি সাথে সাথে কোনো নির্দিষ্ট NUMA
node-এ physical memory বরাদ্দ হয়ে যায়?
না। malloc() (আর তার নিচের mmap()/brk()) শুধু একটা
virtual address range সংরক্ষণ করে। কোনো physical page
তখনো বরাদ্দ হয়নি — Linux memory management lazy, আসল
physical page বরাদ্দ হয় প্রথম page fault-এ, অর্থাৎ প্রথমবার
সেই memory-তে লেখা হলে (বা কোনো কোনো ক্ষেত্রে পড়া হলে)।
Linux-এর ডিফল্ট নীতি — first-touch:
যে CPU প্রথমবার একটা page-এ write করে, সেই page-টা সেই CPU-র নিজের NUMA node-এ বরাদ্দ হয় —
malloc()কোন thread/CPU থেকে ডাকা হয়েছিল তার সাথে কোনো সম্পর্ক নেই।
- thread (node 0-এ চলছে): ptr = malloc(1GB)glibc virtual address range রিজার্ভ করে, কোনো physical page এখনো নেই
- thread (node 1-এ চলছে): memset(ptr, 0, 1GB)প্রথম write -- page fault ট্রিগার হয় প্রতিটা নতুন page-এ
- kernel page fault handlerfirst-touch নীতি প্রয়োগ: faulting CPU (node 1) কোন node-এ আছে তা দেখে
- physical page node 1-এ বরাদ্দ হয়যদিও malloc() ডাকা হয়েছিল node-0-এ চলা thread থেকে!
- ফলাফলযদি পরে node-0-এর thread এই buffer পড়ে/লেখে, প্রতিটা access REMOTE হবে
এটাই এই লেসনের সবচেয়ে সাধারণ real-world ফাঁদ: একটা main thread একটা বড় buffer allocate করে (কিন্তু শুধু কাঠামো সেট করে, পুরোটা touch করে না), তারপর worker thread-দের মধ্যে ভাগ করে দেয়। যে worker প্রথম সেই অংশটা লেখে, সেই worker-এর node-ই সেই অংশের “বাড়ি” হয়ে যায় — যদি worker assignment আর memory touch-এর ক্রম পরিকল্পিত না হয়, buffer-টা এলোমেলোভাবে দুই node-এ ছড়িয়ে যেতে পারে, প্রতিটা worker পরে নিজের অংশের বাইরের কিছু অংশও পড়লে remote access-এ পড়বে।
ভেতরে কী ঘটছে
Memory affinity নিয়ন্ত্রণ করা — numactl আর libnuma
Command-line স্তরে — numactl:
# পুরো প্রোগ্রামকে node 0-এর core-এ বাঁধুন, আর memory-ও node 0 থেকেই নিন
numactl --cpunodebind=0 --membind=0 ./myprogram
# শুধু preferred (কড়া বাধ্যবাধকতা না, node 0 পূর্ণ হলে অন্য node ব্যবহার করবে)
numactl --preferred=0 ./myprogram
# round-robin -- সব node-এ সমানভাবে interleave করে বরাদ্দ, বড় shared read-only ডেটার জন্য উপযোগী
numactl --interleave=all ./myprogram--membind কড়া (hard) — যদি সেই node-এ যথেষ্ট মুক্ত memory
না থাকে, allocation ব্যর্থ হয় (OOM), অন্য node থেকে নেয়
না। --preferred নরম — চেষ্টা করে, না পারলে অন্য node থেকে
নেয়। --interleave ভিন্ন কৌশল — একটা বড় buffer-কে page-বাই-page
সব node-এ ছড়িয়ে দেয়, যখন কোনো একক thread-এর “own” ডেটা না,
বরং সবাই সমানভাবে ব্যবহার করবে এমন shared read-only ডেটার
জন্য উপযোগী (তখন কোনো একটা node-কে bottleneck বানানোর চেয়ে
গড় remote latency-তে সবাইকে সমানভাবে ভাগ করা ভালো)।
Program স্তরে — libnuma API:
#include <numa.h>
if (numa_available() == -1) { /* NUMA নেই -- এই মেশিন UMA */ }
int node = numa_node_of_cpu(sched_getcpu()); /* বর্তমান CPU কোন node-এ */
void *buf = numa_alloc_onnode(size, node); /* সেই node-এ সরাসরি বরাদ্দ */
numa_run_on_node(node); /* এই thread-কে সেই node-এর CPU-তে বেঁধে দিন */
numa_set_preferred(node); /* ভবিষ্যতের malloc()-এর জন্য preferred node সেট */
numa_free(buf, size);numa_alloc_onnode() এর ভেতরে mbind() syscall ব্যবহার করে
— এটা kernel-কে explicit বলে দেয় “এই virtual address range-এর
page-গুলো এই নির্দিষ্ট node-এই বরাদ্দ করো”, first-touch-এর
সাধারণ নিয়মকে override করে।
Automatic NUMA balancing — kernel-এর নিজস্ব চেষ্টা
Linux kernel (2012 সাল থেকে, CONFIG_NUMA_BALANCING) একটা
স্বয়ংক্রিয় ব্যবস্থা রাখে: kernel periodically page access
pattern স্যাম্পল করে (page-কে সাময়িকভাবে “not present” মার্ক
করে, পরের access-এ fault ধরে বুঝতে পারে কোন CPU সেই page
access করছে), আর যদি দেখে একটা page বারবার একটা “ভুল” (remote)
node-এর CPU থেকে access হচ্ছে, সেই page-টাকে migrate করে
সঠিক node-এ সরিয়ে দেয়, অথবা থ্রেড-কেই সেই node-এ সরানোর
চেষ্টা করে।
cat /proc/sys/kernel/numa_balancing # 1 = চালু (ডিফল্ট আধুনিক kernel-এ)উদাহরণ
একটা naive thread pool-এর NUMA-অন্ধতা
ধরুন একটা সাধারণ thread pool implementation:
/* naive: main thread সব বরাদ্দ করে, worker-রা শুধু ব্যবহার করে */
int main(void) {
double *data = malloc(BIG_SIZE * sizeof(double));
for (long i = 0; i \< BIG_SIZE; i++) data[i] = init_value(i); /* main thread-ই touch করছে */
/* worker pool তৈরি, প্রতিটা core-এ একটা thread, কোনো node-affinity ছাড়া */
for (int i = 0; i \< NUM_WORKERS; i++)
pthread_create(&workers[i], NULL, process_chunk, &data[i * CHUNK]);
...
}এখানে দুইটা independent সমস্যা একসাথে ঘটছে:
সমস্যা ১ — সব page একই node-এ। main() thread যে core-এ
চলছে (ধরুন node 0), first-touch নিয়ম অনুযায়ী পুরো data
array-টা node 0-এর physical memory-তে বরাদ্দ হয়ে যায় —
পুরো ১ GB, কোনো ব্যতিক্রম ছাড়া। এখন যদি অর্ধেক worker
thread node 1-এর core-এ চলে (OS scheduler কোথায় thread
রাখবে তা নিয়ে NUMA সম্পর্কে কিছুই না জেনে সিদ্ধান্ত নিতে
পারে), তাদের প্রতিটা access remote — পুরো ১ GB ডেটার
অর্ধেকের ব্যবহার সিস্টেমেটিকভাবে ~১.৫–২× ধীর।
সমস্যা ২ — thread migration। যদি worker thread-গুলো explicit affinity ছাড়া রাখা হয়, Linux scheduler (load balancing-এর স্বার্থে, Level 12-এর CFS/EEVDF আলোচনার সরাসরি প্রয়োগ) মাঝেমধ্যে একটা thread-কে এক node থেকে আরেক node-এ সরিয়ে দিতে পারে — এমনকি যদি প্রাথমিকভাবে সঠিক node-এ চালু হয়েও থাকে, কিছুক্ষণ পরে “ভুল” node-এ চলে যেতে পারে, আর তখন তার সব local access হঠাৎ remote হয়ে যায়।
পরিমাপিত প্রভাব: dual-socket সার্ভারে বাস্তব benchmark-এ (memory-bound workload — বড় array-তে random access বা streaming reduction) এই ধরনের naive thread pool একটা NUMA-aware সংস্করণের তুলনায় ৩০–৫০% কম throughput দেখায় — শুধু layout আর placement-এর কারণে, কোনো algorithm পরিবর্তন ছাড়া। এটা এই লেসনের driving-question-সংযোগ: গত লেসনের false sharing-এর মতোই, এখানেও “একই algorithm, একই complexity” — কিন্তু ভিন্ন হার্ডওয়্যার-সচেতনতায় ভিন্ন গতি।
নিজে চালিয়ে দেখুন
Local বনাম remote access latency নিজে মাপুন
Pointer-chasing কৌশল ব্যবহার করা হচ্ছে (prefetcher-কে এড়াতে — cache/TLB লেসনগুলোর পরিচিত কৌশল), যাতে শুধু প্রকৃত memory latency মাপা যায়, cache hit না।
/* numa_latency.c -- gcc -O2 -o numa_latency numa_latency.c -lnuma */
#define _GNU_SOURCE
#include <numa.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <sched.h>
#define NPTRS (64UL * 1024 * 1024 / sizeof(void*)) /* ৬৪ MB -- cache-এর বাইরে */
double measure_on_node(int cpu_node, int mem_node) {
numa_run_on_node(cpu_node);
void **arr = numa_alloc_onnode(NPTRS * sizeof(void *), mem_node);
/* random permutation -- একটা pointer-chasing cycle, prefetcher-প্রতিরোধী */
long *idx = malloc(NPTRS * sizeof(long));
for (long i = 0; i \< NPTRS; i++) idx[i] = i;
for (long i = NPTRS - 1; i > 0; i--) {
long j = rand() % (i + 1);
long t = idx[i]; idx[i] = idx[j]; idx[j] = t;
}
for (long i = 0; i \< NPTRS; i++) arr[idx[i]] = &arr[idx[(i + 1) % NPTRS]];
void *p = arr[0];
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (long i = 0; i \< NPTRS; i++) p = *(void **)p; /* খাঁটি dependent load, একটার পর একটা */
clock_gettime(CLOCK_MONOTONIC, &t1);
double ns = ((t1.tv_sec - t0.tv_sec) * 1e9 + (t1.tv_nsec - t0.tv_nsec)) / (double)NPTRS;
(void)p;
numa_free(arr, NPTRS * sizeof(void *));
free(idx);
return ns;
}
int main(void) {
if (numa_available() == -1) { printf("এই মেশিনে NUMA নেই\n"); return 1; }
printf("CPU node 0 থেকে node 0-এর memory (LOCAL): %.1f ns/access\n", measure_on_node(0, 0));
printf("CPU node 0 থেকে node 1-এর memory (REMOTE): %.1f ns/access\n", measure_on_node(0, 1));
return 0;
}gcc -O2 -o numa_latency numa_latency.c -lnuma && ./numa_latencyপ্রত্যাশিত ধরনের output:
CPU node 0 থেকে node 0-এর memory (LOCAL): 89.3 ns/access
CPU node 0 থেকে node 1-এর memory (REMOTE): 156.7 ns/access156.7 / 89.3 ≈ 1.75× — ঠিক এই লেসনের শুরুতে বলা “~১.৫–২×”
অনুমানের মধ্যে। নিজের মেশিনের numactl --hardware-এর node
distance টেবিলের সাথে এই অনুপাত মিলিয়ে দেখুন — সাধারণত খুব
কাছাকাছি আসে, কারণ distance টেবিলটাই মূলত এই latency অনুপাত
থেকে derive করা।
একই CPU থেকে একই ধরনের memory access, শুধু ভিন্ন NUMA node-এ, পরিমাপযোগ্য ভিন্ন latency দেয় -- 'RAM' uniform নয় এই দাবিটার সরাসরি প্রমাণ।
নিজে বানান
NUMA-aware বনাম naive thread pool -- মাপা তুলনা
- একটা naive thread pool লিখুন -- main thread পুরো shared buffer allocate + init করে
- একটা NUMA-aware সংস্করণ লিখুন -- প্রতিটা worker numa_alloc_onnode দিয়ে নিজের chunk allocate ও init করে
- দুই সংস্করণেই একই memory-bound workload চালান (যেমন বড় array reduction/sum)
- numactl --hardware দিয়ে node distance নোট করুন, তারপর দুই সংস্করণের throughput তুলনা করুন
- numa_balancing বন্ধ করে (sysctl kernel.numa_balancing=0) naive সংস্করণ আবার চালিয়ে পার্থক্য দেখুন
/* numa_pool.c -- gcc -O2 -pthread -o numa_pool numa_pool.c -lnuma */
#define _GNU_SOURCE
#include <numa.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sched.h>
#include <time.h>
#define NWORKERS 8
#define CHUNK (64UL * 1024 * 1024) /* প্রতি worker ৬৪ M doubles */
static int aware_mode;
static double *shared_buf; /* naive মোডে ব্যবহৃত */
void *naive_worker(void *arg) {
long id = (long)arg;
double *chunk = shared_buf + id * CHUNK;
double sum = 0;
for (long i = 0; i \< CHUNK; i++) sum += chunk[i];
return (void *)(long)sum;
}
void *aware_worker(void *arg) {
long id = (long)arg;
int node = numa_node_of_cpu(sched_getcpu());
numa_run_on_node(node);
double *local = numa_alloc_onnode(CHUNK * sizeof(double), node);
for (long i = 0; i \< CHUNK; i++) local[i] = (double)(id + i); /* এই thread-ই first-touch করছে */
double sum = 0;
for (long i = 0; i \< CHUNK; i++) sum += local[i];
numa_free(local, CHUNK * sizeof(double));
return (void *)(long)sum;
}
double run(int aware) {
aware_mode = aware;
pthread_t t[NWORKERS];
struct timespec t0, t1;
if (!aware) {
shared_buf = malloc(NWORKERS * CHUNK * sizeof(double));
for (long i = 0; i \< NWORKERS * (long)CHUNK; i++) shared_buf[i] = (double)i; /* main thread-ই touch করছে -- সব এক node-এ */
}
clock_gettime(CLOCK_MONOTONIC, &t0);
for (long i = 0; i \< NWORKERS; i++)
pthread_create(&t[i], NULL, aware ? aware_worker : naive_worker, (void *)i);
for (int i = 0; i \< NWORKERS; i++) pthread_join(t[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
if (!aware) free(shared_buf);
return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
}
int main(void) {
if (numa_available() == -1) { printf("NUMA নেই -- এই বেঞ্চমার্ক অর্থহীন এই মেশিনে\n"); return 1; }
printf("naive: %.3f s\n", run(0));
printf("NUMA-aware: %.3f s\n", run(1));
return 0;
}gcc -O2 -pthread -o numa_pool numa_pool.c -lnuma && ./numa_poolপ্রত্যাশিত ধরনের পার্থক্য (dual-socket মেশিনে, worker-দের অর্ধেক node 1-এ scheduled হলে):
naive: 2.910 s
NUMA-aware: 1.740 s~৪০% দ্রুত, শুধু allocation strategy বদলে — ঠিক এই লেসনের “৩০–৫০%” দাবির সাথে সামঞ্জস্যপূর্ণ। কোনো algorithm বদলায়নি, শুধু “কে প্রথম touch করছে” বদলেছে।
নিজে বাড়ান:
numactl --cpunodebind=0 --membind=0দিয়ে সম্পূর্ণ প্রোগ্রামকে জোর করে একটা node-এ বাঁধুন, দেখুন naive সংস্করণও তখন দ্রুত হয় কি না (সব thread + memory একই node-এ থাকলে remote access-এর সুযোগই নেই)numactl --interleave=allদিয়ে চালিয়ে দেখুন naive সংস্করণের উপর প্রভাব — সব page দুই node-এ সমান ছড়িয়ে যাওয়ায় গড় latency উন্নত হয় কি না- Worker সংখ্যা node-এর CPU সংখ্যার চেয়ে বেশি করে দেখুন oversubscription আর NUMA effect একসাথে কীভাবে interact করে
বাস্তব সিস্টেমে
NUMA-awareness যেখানে সরাসরি throughput নির্ধারণ করে
PostgreSQL আর অন্যান্য RDBMS-এর buffer pool partitioning। বড় dual/quad-socket ডাটাবেস সার্ভারে shared buffer cache-কে NUMA node অনুযায়ী partition করা একটা স্বীকৃত optimization — প্রতিটা node-এর query worker যতটা সম্ভব নিজের node-এর buffer partition থেকে ডেটা পড়ে, cross-node traffic কমিয়ে।
JVM-এর -XX:+UseNUMA। HotSpot-এর G1/Parallel garbage
collector NUMA-সচেতন heap allocation সমর্থন করে — নতুন
object সাধারণত সেই thread-এর local node-এ allocate হয় যে
thread সেটা তৈরি করছে, ঠিক এই লেসনের first-touch নীতির
জাভা-স্তরের প্রতিফলন।
Redis/Memcached deployment guide। বড় সার্ভারে এই in-memory
store-গুলো deploy করার সময় প্রায়ই সুপারিশ করা হয় পুরো process-কে
numactl --cpunodebind --membind দিয়ে একটা single node-এ
বেঁধে রাখতে (যদি dataset একটা node-এর memory-তে ধরে) — বরং
পুরো মেশিনের সব core ব্যবহারের চেষ্টা করে cross-node ভাগাভাগির
ঝুঁকি নেওয়ার চেয়ে।
HPC/MPI rank binding। OpenMPI/MPICH-এ --map-by numa বা
--bind-to numa একটা standard flag — প্রতিটা MPI rank-কে
একটা নির্দিষ্ট NUMA node-এ বাঁধা হয়, কারণ scientific computing
workload প্রায়ই memory-bandwidth-bound, আর remote access-এর
bandwidth পেনাল্টি সরাসরি simulation-এর wall-clock সময় বাড়ায়।
Cloud VM sizing এবং NUMA exposure। বড় instance type
(AWS-এর high-vCPU-count instance, GCP-র বড় n2/c2 family,
Azure-র M-series) প্রায়ই একাধিক physical socket বিস্তৃত
করে চলে — cloud provider-রা তখন guest OS-কে প্রকৃত NUMA
topology প্রকাশ করে (lscpu, numactl --hardware VM-এর
ভেতর থেকেই সঠিক multi-node তথ্য দেখায়), যাতে ভেতরের
অ্যাপ্লিকেশন সেই তথ্য কাজে লাগাতে পারে। ছোট instance
(single socket-এর একটা ভাগ) সাধারণত UMA-র মতো আচরণ করে,
কারণ পুরো VM একটা socket-এর মধ্যেই সীমাবদ্ধ।
Kubernetes Topology Manager। বড় multi-NUMA node-এ চলা
Kubernetes cluster-এ Topology Manager একটা pod-এর CPU আর
memory allocation-কে একই NUMA node-এ align করার চেষ্টা
করে (single-numa-node policy) — ঠিক এই লেসনের core নীতি,
কিন্তু container orchestration স্তরে প্রয়োগ করা।
nginx worker_cpu_affinity। High-throughput web server-এ
worker process-কে নির্দিষ্ট CPU-তে pin করা (আর সেই CPU-র
node-এই সংশ্লিষ্ট network buffer/connection state রাখা) একটা
পরিচিত tuning কৌশল বড়, multi-socket সার্ভারে — বিশেষত
NIC interrupt affinity (কোন CPU নেটওয়ার্ক প্যাকেট handle
করছে) এর সাথে মিলিয়ে রাখলে সবচেয়ে বেশি সুবিধা পাওয়া যায়।
যে ভুলগুলো সবাই করে
“NUMA শুধু HPC/scientific computing-এর সমস্যা, সাধারণ সার্ভার সফটওয়্যারে গুরুত্বপূর্ণ না।”
এটা এক দশক আগে আংশিক সত্য ছিল যখন dual-socket সার্ভার বিরল ছিল। আজকের ক্লাউড যুগে বড় instance, database সার্ভার, in-memory cache, এমনকি কিছু bare-metal web server ডিপ্লয়মেন্টও multi-socket হার্ডওয়্যারে চলে — আর এই লেসনের thread-pool উদাহরণটা দেখায়, NUMA-অন্ধতার প্রভাব (৩০-৫০% throughput ক্ষতি) যেকোনো memory-bound, multi-threaded অ্যাপ্লিকেশনে ধরা পড়তে পারে, শুধু scientific simulation-এ না। যেকোনো সার্ভার সফটওয়্যার যেটা বড় in-memory ডেটাসেট নিয়ে কাজ করে আর একাধিক thread ব্যবহার করে, সেটার জন্যই NUMA-awareness প্রাসঙ্গিক।
“malloc() করার মুহূর্তেই memory একটা নির্দিষ্ট NUMA node-এ বসে যায়, তাই allocating thread-কেই সঠিক node-এ রাখলে যথেষ্ট।”
এই লেসনের কেন্দ্রীয় সংশোধন — memory বরাদ্দ দুই ধাপে
ঘটে: virtual address reservation (malloc()-এর সময়, তাৎক্ষণিক)
আর physical page assignment (প্রথম write-এর সময়, first-touch
নীতি অনুযায়ী)। যে thread malloc() কল করেছে সে যদি কখনো
সেই memory স্পর্শ না করে, তার node-এর কোনো ভূমিকাই নেই
placement-এ। গুরুত্বপূর্ণ হলো কোন thread প্রথম write করছে
— সেটাই নির্ধারণ করে physical page কোন node-এ যাবে।
“থ্রেড-কে একটা নির্দিষ্ট NUMA node-এ pin করলে সবসময়ই পারফরম্যান্স বাড়ে।”
সবসময় না — pinning একটা trade-off, বিনামূল্যের সুবিধা
না। যদি workload-টা এমন হয় যে একটা node-এর মেমরির চেয়ে বেশি
ডেটা লাগে (dataset node-এর local RAM-এর চেয়ে বড়), জোর করে
--membind দিয়ে একটা node-এ বাঁধলে allocation ব্যর্থ হবে
বা সেই node-কে overload করে ফেলবে, যেখানে remote access
হলেও অন্তত কাজ সম্পন্ন হতো। আরেকটা ঝুঁকি: যদি কাজের বণ্টন
threads-এর মধ্যে অসম হয় (কিছু thread বেশি কাজ পায়), কড়াভাবে
node-bound করা load imbalance তৈরি করতে পারে — একটা node-এর
core ব্যস্ত, আরেকটা node অলস, অথচ pinning-এর কারণে কাজ
সরানো যাচ্ছে না। সবসময় মেপে সিদ্ধান্ত নিন — numactl --preferred (নরম বাধ্যবাধকতা) প্রায়ই --membind (কড়া)-এর
চেয়ে নিরাপদ ডিফল্ট পছন্দ, ঠিক যেমন আগের লেসনগুলোতে বারবার
দেখা “measure, don’t assume” নীতি।
“একটা virtual machine-এর ভেতরে NUMA নিয়ে চিন্তা করার দরকার নেই -- সেটা hypervisor-এর কাজ।”
ছোট VM-এ (একটা socket-এর একটা ভাগ) এটা সত্য — hypervisor পুরো VM-টাকেই একটা node-এর মধ্যে রাখে, guest OS UMA-র মতো আচরণ পায়। কিন্তু বড় VM-এ (একাধিক socket জুড়ে বিস্তৃত, আধুনিক cloud provider-দের বড় instance type-এ সাধারণ) hypervisor প্রকৃত multi-node topology guest-কে প্রকাশ করে (virtual NUMA/vNUMA), ঠিক এই লেসনের realworld সেকশনে বলা হয়েছে। এই ক্ষেত্রে VM-এর ভেতরের অ্যাপ্লিকেশনকেও ঠিক ততটাই NUMA-সচেতন হতে হয় যতটা bare-metal-এ হতো — virtualization layer এই সমস্যাটা “সমাধান” করে না, শুধু এক স্তর উপরে তুলে ধরে।
বুঝেছেন কি না দেখুন
1false sharing (আগের লেসন) আর NUMA remote access (এই লেসন) —
দুটোই “একটা memory access প্রত্যাশার চেয়ে ধীর” সমস্যা।
তাদের মধ্যে মৌলিক পার্থক্যটা কী — একটা এক-বাক্যে বলুন, তারপর
ব্যাখ্যা করুন কেন এই পার্থক্যটা তাদের সমাধানও ভিন্ন করে দেয়।
যুক্তি
এক বাক্যে: false sharing একটা গ্রানুলারিটি সমস্যা (hardware কতটুকু ডেটা একসাথে ট্র্যাক করে), আর NUMA একটা দূরত্ব সমস্যা (ডেটা ভৌতভাবে কোথায় বসে আছে)।
বিস্তারিত: False sharing ঘটে কারণ cache coherence protocol একটা পুরো cache line-কে (৬৪ বাইট) একক ইউনিট হিসেবে দেখে — দুইটা logically independent variable যদি সেই একক ইউনিটের ভেতরে পড়ে, protocol তাদের আলাদা করতে পারে না। সমাধান তাই layout-ভিত্তিক এবং সস্তা: শুধু কিছু বাইট padding যোগ করে দুইটা variable-কে দুইটা আলাদা line-এ ঠেলে দিলেই সমস্যা সম্পূর্ণ অদৃশ্য হয়ে যায় — কোনো ভৌত দূরত্ব বদলায়নি, শুধু “কোন গ্রুপে পড়ে” তা বদলেছে।
NUMA remote access সম্পূর্ণ ভিন্ন প্রকৃতির — এখানে সমস্যাটা কোনো ভুল গ্রুপিং না, বরং একটা ভৌত বাস্তবতা: ডেটা সত্যিই socket 1-এর DRAM chip-এ বসে আছে, আর আপনার thread সত্যিই socket 0-এর core-এ চলছে, তাদের মধ্যে সত্যিই একটা ভৌত তার (interconnect) আছে যেটা পার হতেই হবে। এখানে কোনো “layout বদলানো” দিয়ে দূরত্বটা শূন্য করা যায় না — সমাধান হলো placement: হয় ডেটাকে thread-এর কাছে আনা (memory affinity), নয়তো thread-কে ডেটার কাছে পাঠানো (thread affinity), কিন্তু দুই node-এর মধ্যেকার ভৌত দূরত্ব নিজেই কখনো দূর করা যায় না।
ব্যবহারিক ফলাফল: false sharing-এর সমাধান প্রায় বিনামূল্যে (কয়েক বাইট memory), এক জায়গায় (struct definition) ঠিক করলেই স্থায়ীভাবে সমাধান। NUMA-র সমাধান চলমান সিদ্ধান্তের ব্যাপার (কোন thread কোন core-এ, কোন ডেটা কোন node-এ) — এবং workload পরিবর্তনের সাথে সাথে সেই সিদ্ধান্ত আবার ভুল হয়ে যেতে পারে (যেমন thread migration), যেখানে false sharing-এর সমাধান একবার সঠিক করলে চিরকাল সঠিক থাকে (যতক্ষণ struct layout না বদলায়)।
2একটা প্রোগ্রামে দেখলেন:
double *buf = malloc(N * sizeof(double));
#pragma omp parallel for
for (long i = 0; i \< N; i++) buf[i] = 0.0; /* init, সব thread মিলে সমান্তরালে */
#pragma omp parallel for
for (long i = 0; i \< N; i++) buf[i] = compute(i); /* আসল কাজ, একই thread-distribution */
এখানে #pragma omp parallel for ডিফল্টভাবে array-টাকে সব
thread-এর মধ্যে সমান chunk-এ ভাগ করে (thread 0 পায় প্রথম
N/T অংশ, ইত্যাদি)। এই কোডে NUMA placement কেমন হবে বলে
আশা করেন, আর কেন?
প্রয়োগ
double *buf = malloc(N * sizeof(double));
#pragma omp parallel for
for (long i = 0; i \< N; i++) buf[i] = 0.0; /* init, সব thread মিলে সমান্তরালে */
#pragma omp parallel for
for (long i = 0; i \< N; i++) buf[i] = compute(i); /* আসল কাজ, একই thread-distribution */#pragma omp parallel for ডিফল্টভাবে array-টাকে সব
thread-এর মধ্যে সমান chunk-এ ভাগ করে (thread 0 পায় প্রথম
N/T অংশ, ইত্যাদি)। এই কোডে NUMA placement কেমন হবে বলে
আশা করেন, আর কেন?এই কোডটা আসলে ভালো — সম্ভবত প্রায় optimal placement দেবে, কাকতালীয়ভাবে না, বরং একটা গুরুত্বপূর্ণ নীতির কারণে।
লক্ষ করুন প্রথম parallel loop-টাই init করছে — এটাই
buf-এর প্রতিটা element-এর প্রথম write, অর্থাৎ first-touch
মুহূর্ত। যেহেতু এই init loop-ও OpenMP দিয়ে সমান্তরাল, আর
OpenMP ডিফল্টভাবে একই chunk-বণ্টন নীতি ব্যবহার করে (static
scheduling হলে thread t সবসময় একই index-রেঞ্জ পায়, প্রতিটা
parallel for-এ), তাই:
- init loop-এ thread
tbuf[t*N/T .. (t+1)*N/T]লেখে → সেই অংশ threadt-র node-এ প্রথম-touch হয় - আসল কাজের loop-এও thread
tসেই একই index-রেঞ্জ পায় (একই static scheduling) → threadtতার নিজের already-local করে রাখা অংশেই কাজ করছে
ফলাফল: প্রতিটা thread তার কাজের ডেটা নিজের local node থেকেই পড়ছে-লিখছে, কোনো remote access ছাড়াই — এটাই “যে ব্যবহার করবে, সে-ই আগে touch করুক” নীতির সঠিক প্রয়োগ, যদিও প্রোগ্রামার হয়তো সচেতনভাবে NUMA নিয়ে কিছু ভাবেননি।
এটা কখন ভেঙে যায়: যদি OpenMP scheduling dynamic বা
guided হয় (কাজের ভাগ runtime-এ elastic ভাবে বণ্টন হয়, load
balancing-এর জন্য), তাহলে init loop আর কাজের loop-এ একই
thread একই index পাবে এই নিশ্চয়তা আর থাকে না — thread
0 init-এ [0, 1000) পেতে পারে, কিন্তু কাজের loop-এ ভিন্ন
[3000, 4000) পেতে পারে (dynamic load-balancing-এর কারণে)
— তখন প্রথম-touch-এর সুবিধাটা হারিয়ে যায়, আর সমস্যাটা এই
লেসনের naive thread-pool উদাহরণের মতোই ফিরে আসে।
সাধারণ শিক্ষা: static scheduling আর একই data-partitioning
সব phase-এ পুনরাব্যবহার করা — init থেকে কাজ পর্যন্ত — একটা
সস্তা, প্রায়ই automatic (কোনো explicit numa_alloc_onnode
ছাড়াই) NUMA-optimization কৌশল, যতক্ষণ scheduling নীতি
consistent থাকে।
3আপনি একটা key-value cache সার্ভার ডিজাইন করছেন যা একটা
২-সকেট, ৪৮-কোর মেশিনে চলবে, পুরো dataset (৪০০ GB) দুই
node-এর মোট RAM (৫১২ GB)-এর মধ্যে ধরে কিন্তু একটা single
node-এর RAM (২৫৬ GB)-এর চেয়ে বড়। ডিজাইন করুন — dataset
কীভাবে ভাগ করবেন, আর কেন।
ডিজাইন
যেহেতু dataset একটা single node-এ ধরবে না, --membind
দিয়ে সম্পূর্ণ single-node বাধ্যবাধকতা অসম্ভব — dataset-কে
sharding করতেই হবে দুই node-এর মধ্যে। মূল ডিজাইন
সিদ্ধান্ত: sharding key routing-কে NUMA node-এর সাথে align
করা।
ডিজাইন:
১. dataset-কে দুইটা shard-এ ভাগ করুন (key hash অনুযায়ী,
যেমন hash(key) % 2), প্রতিটা shard ~২০০ GB — উভয়েই
একটা single node-এর RAM-এ আরামে ধরে।
২. প্রতিটা shard-এর জন্য আলাদা worker thread pool তৈরি
করুন, প্রতিটা pool নির্দিষ্ট একটা NUMA node-এ pin
(numa_run_on_node) — shard 0-এর worker-রা node 0-এ,
shard 1-এর worker-রা node 1-এ।
৩. প্রতিটা shard-এর ডেটা structure (hash table, LRU list
ইত্যাদি) সেই shard-এর নিজস্ব worker দিয়েই allocate + init
করুন (numa_alloc_onnode + first-touch নীতি মেনে) — এতে
পুরো ২০০ GB shard তার নিজের node-এর local memory-তে বসে।
৪. Incoming request routing স্তরে: request-টা কোন key-র জন্য তা দেখে সরাসরি সেই shard-এর node-bound worker-এ পাঠান — একটা request কখনো এক node-এর worker থেকে অন্য node-এর ডেটা পড়ার চেষ্টা করবে না।
কেন এই ডিজাইন: এটা প্রতিটা request-কে সম্পূর্ণ local রাখে — কোনো single request cross-node latency পেনাল্টি পায় না, যদিও সামগ্রিকভাবে সিস্টেম দুই node ব্যবহার করছে। এটা এই লেসনের “distance দূর করা যায় না, শুধু avoid করা যায়” নীতির সরাসরি প্রয়োগ — dataset ভাগ করে প্রতিটা ভাগকে তার নিজের node-এর মধ্যে সীমাবদ্ধ রাখা হয়েছে, কোনো একক request-কে দুই node-এর মধ্যে ছড়াতে হচ্ছে না।
Trade-off যা স্বীকার করা দরকার: যদি key distribution অসম হয় (কিছু key অনেক বেশি “hot”), একটা shard/node অন্যটার চেয়ে বেশি ব্যস্ত হতে পারে — load imbalance, যেটা misconception সেকশনে উল্লেখ করা ঝুঁকিরই বাস্তব রূপ। এই ক্ষেত্রে consistent hashing-এর সাথে periodic rebalancing, বা hot key-দের জন্য দুই node-এই replica রাখা (write-এ সামান্য বাড়তি খরচ, কিন্তু read load ভাগ হয়ে যায়) — একটা যুক্তিসঙ্গত পরবর্তী ধাপ।
4numactl --interleave=all কখন ব্যবহার করা উচিত, আর কখন
numactl --membind=<node> বেশি যুক্তিসঙ্গত? দুইটার মধ্যে
মৌলিক পার্থক্য কী?
স্মরণ
numactl --interleave=all কখন ব্যবহার করা উচিত, আর কখন
numactl --membind=<node> বেশি যুক্তিসঙ্গত? দুইটার মধ্যে
মৌলিক পার্থক্য কী?পার্থক্যটা নির্ভর করে ডেটা কে ব্যবহার করবে তার উপর — একজন owner, নাকি সবাই সমানভাবে।
--membind=<node> — ডেটার একটা নির্দিষ্ট, single owner
আছে (একটা thread বা thread-দের একটা group যারা সবাই একই
node-এ থাকবে)। পুরো buffer সেই একটা node-এ রাখলে সেই owner-এর
জন্য সব access local হয়। উপরের key-value cache উদাহরণে
প্রতিটা shard-এর জন্য এটাই সঠিক পছন্দ — shard 0-এর ডেটার
“owner” হলো node-0-এর worker-রা।
--interleave=all — ডেটা সবার জন্য সমানভাবে গুরুত্বপূর্ণ,
কোনো একক owner নেই — যেমন একটা বড়, read-only lookup table
বা shared configuration যা প্রতিটা node-এর worker সমান
ফ্রিকোয়েন্সিতে পড়ে। এই ক্ষেত্রে সম্পূর্ণ buffer-কে একটা
node-এ রাখলে সেই node-এর worker-রা local access পাবে, কিন্তু
বাকি সব node-এর worker-রা সবসময় remote access পাবে — একটা
node-কে অন্যায্যভাবে সুবিধা দেওয়া। Interleaving page-বাই-page
সব node-এ ছড়িয়ে দেয়, তাই কোনো node-ই বিশেষভাবে সুবিধা
পায় না, কিন্তু কেউই সম্পূর্ণ বঞ্চিতও হয় না — গড় latency
সবার জন্য মোটামুটি সমান (local আর remote-এর মাঝামাঝি একটা
গড়), যা একক-owner-না-থাকা পরিস্থিতিতে সবচেয়ে ন্যায্য ফলাফল।
নিয়ম: যদি প্রশ্ন করতে পারেন “এই ডেটার প্রধান ব্যবহারকারী
কে” আর একটা স্পষ্ট উত্তর পান — --membind/numa_alloc_onnode
সেই node-এ। যদি উত্তর হয় “সবাই সমানভাবে” — --interleave।
5/proc/sys/kernel/numa_balancing চালু (1) থাকা অবস্থায়
একটা প্রোগ্রাম numa_alloc_onnode() দিয়ে explicit memory
placement করেছে, thread-কেও numa_run_on_node() দিয়ে বেঁধেছে।
তাও production-এ মাঝেমধ্যে অপ্রত্যাশিতভাবে remote access
latency দেখা যাচ্ছে। সম্ভাব্য কারণ কী হতে পারে?
যুক্তি
/proc/sys/kernel/numa_balancing চালু (1) থাকা অবস্থায়
একটা প্রোগ্রাম numa_alloc_onnode() দিয়ে explicit memory
placement করেছে, thread-কেও numa_run_on_node() দিয়ে বেঁধেছে।
তাও production-এ মাঝেমধ্যে অপ্রত্যাশিতভাবে remote access
latency দেখা যাচ্ছে। সম্ভাব্য কারণ কী হতে পারে?সবচেয়ে সম্ভাব্য কারণ: automatic NUMA balancing আর explicit placement একে অপরের সাথে conflict করছে।
Automatic balancing kernel-এর নিজস্ব heuristic অনুযায়ী চলে
— এটা periodically page access pattern স্যাম্পল করে, আর
যদি ভুলভাবে মনে করে একটা page “ভুল” node-এ আছে (হয়তো
সাময়িক access pattern-এর কারণে, প্রকৃত দীর্ঘমেয়াদি ব্যবহার
বিবেচনা না করে), সেটা সেই page-কে migrate করে দিতে পারে —
এমনকি যদি সেই page ইচ্ছাকৃতভাবে numa_alloc_onnode() দিয়ে
একটা নির্দিষ্ট node-এ বসানো হয়ে থাকে। একইভাবে, kernel-এর
নিজস্ব scheduler load-balancing সিদ্ধান্তে একটা thread-কে
সাময়িকভাবে অন্য node-এর CPU-তে সরাতে পারে, numa_run_on_node()
সত্ত্বেও (যদি না একটা কঠোর CPU affinity mask, যেমন
sched_setaffinity(), একইসাথে সেট করা হয় — numa_run_on_node()
নিজে একটা soft preference, hard pinning না সব ক্ষেত্রে)।
সমাধান-পথ:
১. যদি placement সত্যিই ইচ্ছাকৃত ও স্থির হওয়া উচিত হয়,
automatic balancing বন্ধ করুন সেই নির্দিষ্ট
প্রোসেস/workload-এর জন্য (echo 0 > /proc/sys/kernel/numa_balancing, অথবা numactl এর
--strict জাতীয় বিকল্প ব্যবহার করে kernel-কে override
না করার নির্দেশ দিন)।
২. Thread affinity-র জন্য শুধু numa_run_on_node() না, সরাসরি
sched_setaffinity() দিয়ে একটা hard CPU mask সেট করুন
যাতে scheduler-এর load-balancer সেই thread-কে অন্য node-এ
সরাতে না পারে।
৩. Memory-র জন্য mbind() (যা numa_alloc_onnode() ভেতরে
ব্যবহার করে) MPOL_BIND মোডে ব্যবহার করুন (কড়া), শুধু
MPOL_PREFERRED (নরম, যা automatic balancing-এর জন্য
বেশি নমনীয়/override-যোগ্য) না।
সাধারণ নীতি: automatic heuristic (kernel-এর balancing)
আর manual/explicit control (আপনার numactl/libnuma কল)
— দুটো একই decision-space-এ প্রতিযোগিতা করলে অপ্রত্যাশিত
আচরণ স্বাভাবিক। যখন একটা workload-এর placement প্রয়োজনীয়তা
ভালোভাবে বোঝা এবং static (এই লেসনের key-value cache-এর
মতো), সাধারণত heuristic বন্ধ করে সম্পূর্ণ manual control
নেওয়াই বেশি predictable — heuristic তখনই বেশি উপযোগী যখন
placement প্রয়োজনীয়তা আগে থেকে জানা নেই বা সময়ের সাথে বদলায়।
এরপর কী
এরপর — জায়গার পর, সময়ের প্রশ্ন
গত দুই লেসনে আমরা দেখলাম হার্ডওয়্যার-সচেতনতার দুইটা ভিন্ন মাত্রা — কোন cache line (false sharing) আর কোন memory node (NUMA)। দুটোই মূলত একটা প্রশ্নের ভিন্ন রূপ: “ডেটা কোথায় বসে আছে, আর সেটা কোন core-এর কতটা কাছে?”
পরের লেসনে প্রশ্নটা সম্পূর্ণ বদলে যাবে। “কোথায়” না, বরং “কোন ক্রমে” — যখন একটা core দুইটা ভিন্ন memory address-এ লেখে, অন্য সব core কি সেই দুইটা লেখা একই ক্রমে দেখে, নাকি হার্ডওয়্যার সেগুলোকে reorder করে দিতে পারে? এটা cache coherence-এর মতো শোনালেও সম্পূর্ণ ভিন্ন একটা সমস্যা — coherence একটা single address-এর visibility নিয়ে, আজকের বিষয়টা একাধিক address-এর মধ্যেকার order নিয়ে। এটাই memory consistency model।
আরও পড়ুন
- What Every Programmer Should Know About Memory, §6 — NUMA Support — Ulrich Drepper · NUMA-র উপর সবচেয়ে detailed, বিনামূল্যে উপলব্ধ প্রযুক্তিগত আলোচনা — first-touch policy আর libnuma API-র মূল উৎস
- numactl(8) এবং numa(3) man pages · প্রামাণ্য কমান্ড-লাইন আর libnuma API রেফারেন্স
- Linux kernel documentation — Automatic NUMA Balancing · kernel কীভাবে স্বয়ংক্রিয়ভাবে page/thread migration করে remote access কমানোর চেষ্টা করে