Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ৭/২৯অ্যাডভান্সড১ ঘণ্টা ১০ মিনিট

Context Switching — যা সংরক্ষিত হয়, আর যার প্রকৃত দাম

Context Switching

গত লেসনে দুইটা সংখ্যা ব্যবহার করেছি প্রমাণ ছাড়াই — thread switch-এর খরচ '~১-৩ μs', আর একই thread group-এ switch সস্তা কারণ 'CR3 বদলায় না'। এই লেসনে সেই দাবিগুলো প্রমাণ করব — ঠিক কোন বাইটগুলো সংরক্ষিত হয়, সরাসরি খরচ কত, আর তার চেয়েও বড় পরোক্ষ খরচ (cache, TLB, branch predictor) কত। শেষে নিজের মেশিনে মেপে দেখব।

এই লেসন শেষে আপনি পারবেন

  • একটা context switch-এ ঠিক কী কী সংরক্ষিত ও পুনরুদ্ধার হয় তা তালিকাভুক্ত করতে পারবেন — thread switch ও process switch-এর পার্থক্যসহ
  • Direct cost (register save/restore) আর indirect cost (cache, TLB, branch predictor)-এর মধ্যে পার্থক্য করতে পারবেন, আর কেন দ্বিতীয়টা সাধারণত অনেক বড় তা ব্যাখ্যা করতে পারবেন
  • PCID/ASID কীভাবে TLB flush এড়ায় তা বর্ণনা করতে পারবেন
  • Voluntary ও involuntary context switch-এর পার্থক্য করতে পারবেন এবং `/proc/<pid>/status` থেকে সেগুলো পড়তে পারবেন
  • নিজের মেশিনে context switch-এর প্রকৃত খরচ পরিমাপ করতে পারবেন, আর same-core বনাম cross-core scheduling-এর প্রভাব দেখাতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আপনার CPU-তে হয়তো ৮টা core আছে। এই মুহূর্তে আপনার মেশিনে হয়তো ৩০০টা process, ২০০০টা thread চলছে। তাহলে ৮টা core কীভাবে ২০০০টা thread “একসাথে” চালায়?

উত্তর: চালায় না। প্রতিটা core এক মুহূর্তে একটাই thread চালায়। যা আমরা “একসাথে চলছে” বলে অনুভব করি, সেটা আসলে scheduler-এর একটা বিভ্রম — প্রতিটা core millisecond-এর ভগ্নাংশে থ্রেড বদলাতে থাকে, এত দ্রুত যে মানুষের চোখে সমান্তরাল মনে হয়।

এই “বদলানো”-র নাম context switch — একটা running thread-কে থামিয়ে তার অবস্থা সংরক্ষণ করা, তারপর আরেকটা thread-এর অবস্থা ফিরিয়ে এনে সেখান থেকে চালানো শুরু করা। যেন কেউ একটা বই পড়তে পড়তে মাঝপথে বুকমার্ক রেখে আরেকটা বই তুলে নিল, ঠিক যেখানে রেখেছিল সেখান থেকে পড়া শুরু করল।

আজ আমরা দেখব এই “বুকমার্ক রাখা”-র প্রকৃত মূল্য কত — আর কেন সেই মূল্যের বেশিরভাগটাই এমন জায়গায় যেখানে আপনি প্রথমে তাকাবেনই না।

মূল ধারণা

কী সংরক্ষিত হয় — thread switch

একটা CPU core-এর “অবস্থা” মানে তার register-গুলোর মান। Thread switch করতে হলে বর্তমান thread-এর register-গুলো কোথাও রাখতে হবে, তারপর নতুন thread-এর আগে-সংরক্ষিত register-গুলো ফিরিয়ে আনতে হবে।

x86-64-এ একটা সাধারণ thread switch-এ যা সংরক্ষিত হয়:

Registerকী
rax, rbx, rcx, rdxgeneral purpose
rsi, rdi, rbp, rspindex/pointer (rsp = stack pointer)
r8r15আরো general purpose
ripprogram counter (implicitly, return address দিয়ে)
rflagscondition flag
fs, gs basethread-local storage pointer (গত লেসনের TLS)
FPU/SSE/AVX statefloating-point ও SIMD register (যদি ব্যবহৃত হয়)

এই সবকিছু যায় thread-এর kernel stack-এ (প্রতিটা thread-এর নিজস্ব ছোট kernel-এর ভেতরের stack থাকে, user stack থেকে আলাদা)।

কোথায় এই সংরক্ষণ-পুনরুদ্ধার ঘটে: Linux kernel-এ একটা architecture-নির্ভর function, switch_to() — যেটা আক্ষরিক অর্থে assembly-তে লেখা, কারণ এটা এমন একটা কোড যা নিজেই যে register ব্যবহার করছে সেগুলো বদলে ফেলছে মাঝপথে। C compiler এই ধরনের কোড সঠিকভাবে জেনারেট করতে পারবে না — এটাই একটা বিরল জায়গা যেখানে kernel hand-written assembly-র উপর নির্ভর করে।

কী সংরক্ষিত হয় — process switch (বাড়তি একটা জিনিস)

গত লেসনে দেখেছি thread আর process-এর মূল পার্থক্য: address space শেয়ার করা কি না। তাই যখন switch টা একই process-এর দুইটা thread-এর মধ্যে হয় (থ্রেড switch), উপরের তালিকাই যথেষ্ট।

কিন্তু switch যদি ভিন্ন process-এর মধ্যে হয়, তাহলে একটা বাড়তি, ব্যয়বহুল ধাপ লাগে: page table root বদলানো

CR3 register  →  বর্তমান process-এর page table-এর physical address

CR3 বদলানো মানে CPU-কে বলা “এখন থেকে virtual address-গুলো অন্য একটা page table দিয়ে translate করো” (Level 4-এর paging লেসনে এই mechanism বিস্তারিত)। আর এই বদলের একটা সরাসরি, ব্যয়বহুল পরিণতি আছে — TLB flush।

Process switch-এ বাড়তি যা ঘটে (thread switch-এর তুলনায়)
  1. Register save/restorethread switch-এর মতোই — উভয় ক্ষেত্রেই লাগে
  2. CR3 নতুন page table-এ নির্দেশ করেনতুন address space সক্রিয়
  3. পুরনো TLB entry এখন ভুলএকই virtual address এখন ভিন্ন কিছু বোঝাতে পারে
  4. TLB flush (PCID ছাড়া)সব cached translation বাতিল
  5. পরবর্তী প্রতিটা memory accessTLB miss → page walk → ধীর, যতক্ষণ না TLB আবার ভরে

TLB-র লেসনে দেখেছি TLB miss-এর খরচ ~১০০+ cycle, hit-এর খরচ প্রায় শূন্য। একটা পুরো TLB flush মানে প্রতিটা পরের memory access সাময়িকভাবে সেই দামি path দিয়ে যাবে, যতক্ষণ না TLB আবার ভরে ওঠে (warm হয়)।

ভেতরে কী ঘটছে

Direct cost বনাম indirect cost — আসল গল্প

Direct cost — register save/restore, kernel-এর হিসাব-নিকাশ (scheduler বেছে নেওয়া, task_struct আপডেট)। এটা মাপা সহজ — কয়েকশো nanosecond থেকে কয়েক microsecond।

Indirect cost — অনেক বড়, কিন্তু সরাসরি “context switch” নামে দেখা যায় না:

Indirect costকেন ঘটে
Cold cacheনতুন thread-এর ডেটা L1/L2-তে নেই, মেমরি থেকে আনতে হয়
TLB flush (process switch-এ)প্রতিটা translation নতুন করে করতে হয়
Branch predictor pollutionপুরনো thread-এর branch history নতুন thread-এর জন্য ভুল অনুমান দেয়
Pipeline flushspeculative execution-এর ফলাফল বাতিল

একটা সংখ্যা দিয়ে বোঝা যাক: ধরুন একটা thread-এর working set (যে ডেটা সে বারবার ব্যবহার করছে) L1 cache-এ আছে — প্রতিটা access ~৪ cycle। Switch হয়ে সেই cache line-গুলো অন্য thread-এর ডেটা দিয়ে প্রতিস্থাপিত হলো (L1 cache মাত্র কয়েক দশ KB, তাই সহজেই পুরোটা উল্টে যায়)। ফিরে এলে প্রতিটা পুরনো access এখন DRAM থেকে — ~২০০ cycle, ৫০ গুণ ধীর — যতক্ষণ না cache আবার warm হয়।

Direct cost একবারই ঘটে, কয়েকশো nanosecond। Indirect cost পরের হাজার হাজার instruction জুড়ে ছড়িয়ে থাকে, প্রতিটা মেমরি access-এ কয়েক extra cycle যোগ করে — মোট প্রভাব প্রায়ই direct cost-এর ১০-১০০ গুণ

PCID/ASID — TLB flush এড়ানোর hardware সমাধান

যদি প্রতিটা process switch-এ TLB সম্পূর্ণ ফাঁকা হয়ে যায়, আর process-গুলো ঘন ঘন পালাক্রমে চলে (যেমন একটা busy সার্ভারে), তাহলে TLB কখনোই warm থাকার সুযোগ পায় না — প্রতিবার আবার miss, আবার miss।

PCID (Process-Context Identifier, x86-এ) সমাধান দেয়: TLB entry-তে একটা ছোট ট্যাগ যোগ করা হয় — কোন process-এর জন্য এই translation। এখন CR3 বদলালেও পুরনো entry মুছে ফেলা লাগে না — শুধু তাদের PCID ট্যাগ ভিন্ন, তাই CPU স্বয়ংক্রিয়ভাবে শুধু বর্তমান process-এর ট্যাগ-ওয়ালা entry ব্যবহার করে।

PCID ছাড়া:  Process A → B → A  →  প্রতিবার সম্পূর্ণ TLB flush
PCID সহ:     Process A → B → A  →  A-এর ফিরে আসায় পুরনো entry এখনো TLB-তে!

ARM-এ একই ধারণার নাম ASID (Address Space Identifier) — সেই একই hardware কৌশল, ভিন্ন নাম।

Voluntary বনাম involuntary switch

Scheduler-এর লেসনের ভাষায় ফিরে গেলে, একটা thread দুইভাবে CPU ছাড়ে:

কখনউদাহরণ
Voluntaryনিজে থেকে block করেread() disk-এর জন্য অপেক্ষা করছে, mutex lock-এর জন্য ঘুমাচ্ছে
InvoluntaryScheduler জোর করে সরায়Time quantum শেষ, উচ্চ-priority thread এসেছে
cat /proc/<pid>/status | grep ctxt_switches
voluntary_ctxt_switches:     1523
nonvoluntary_ctxt_switches:  87

Voluntary বেশি হলে: thread প্রায়ই I/O বা lock-এর জন্য অপেক্ষা করছে — একটা I/O-bound বা lock-contended প্রোগ্রামের লক্ষণ।

Involuntary বেশি হলে: thread CPU-bound (ক্রমাগত কাজ করছে, নিজে কখনো ছাড়ছে না), আর scheduler-কে জোর করে সরাতে হচ্ছে অন্যদের সুযোগ দিতে।

উদাহরণ

একটা সম্পূর্ণ ট্রেস — timer interrupt থেকে switch পর্যন্ত

Thread A চলছে (CPU-bound loop)

    ├─ Timer interrupt আসে (হার্ডওয়্যার, লেসন ২২-এ বিস্তারিত)
    │     │
    │     └─→ CPU privileged mode-এ ঢোকে (গত লেসন)

    ├─ Interrupt handler: scheduler-কে ডাকে
    │     │
    │     └─→ "Thread A-র quantum শেষ, পরেরটা কে?"
    │         CFS/EEVDF থেকে পরবর্তী thread বেছে নেওয়া (লেসন ৯)

    ├─ switch_to(A, B) — assembly-তে
    │     │
    │     ├─→ Thread A-র সব register A-র kernel stack-এ push
    │     ├─→ rsp আপডেট — এখন B-র kernel stack নির্দেশ করছে
    │     ├─→ (যদি A, B ভিন্ন process হয়) CR3 নতুন page table-এ
    │     └─→ Thread B-র আগে-সংরক্ষিত register pop

    └─ Thread B চলতে শুরু করে
          ঠিক সেই instruction থেকে যেখানে সে আগে থামানো হয়েছিল

এই পুরো প্রক্রিয়ায় “সময়ের” ধারণা Thread B-র কাছে অদৃশ্য। সে জানে না কতক্ষণ থামানো ছিল — তার দৃষ্টিতে সে ঠিক পরের instruction-এ চলে গেছে, যেন কিছুই ঘটেনি। এই illusion-টাই multitasking-এর ভিত্তি।

নিজে চালিয়ে দেখুন

EXPERIMENT

μs-প্রতি-switch নিজে মাপুন — pipe ping-pong

Linux, gcc· ১৫ মিনিট
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <sys/wait.h>

#define N 100000

int main(void) {
    int p1[2], p2[2];
    pipe(p1); pipe(p2);

    pid_t pid = fork();
    if (pid == 0) {
        char buf;
        for (int i = 0; i < N; i++) {
            read(p1[0], &buf, 1);
            write(p2[1], &buf, 1);
        }
        _exit(0);
    }

    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);

    char buf = 'x';
    for (int i = 0; i < N; i++) {
        write(p1[1], &buf, 1);
        read(p2[0], &buf, 1);
    }

    clock_gettime(CLOCK_MONOTONIC, &t1);
    waitpid(pid, NULL, 0);

    double total_ns = (t1.tv_sec - t0.tv_sec) * 1e9 +
                       (t1.tv_nsec - t0.tv_nsec);
    /* প্রতিটা round-trip = 2 context switch (parent→child, child→parent) */
    double switches = N * 2.0;
    printf("মোট সময়: %.2f ms, %.0f switch\n", total_ns / 1e6, switches);
    printf("প্রতি switch: %.0f ns (~%.2f μs)\n",
           total_ns / switches, total_ns / switches / 1000);
    return 0;
}
gcc -O2 -o ctxswitch ctxswitch.c && ./ctxswitch

সাধারণ ফলাফল:

মোট সময়: 1847.32 ms, 200000 switch
প্রতি switch: 9236 ns (~9.24 μs)

লক্ষ্য করুন এই সংখ্যাটা লেসন ৬-এ উল্লিখিত “~১-৩ μs”-এর চেয়ে বেশি — কারণ এটা প্রক্রিয়া-স্তরের switch (দুইটা আলাদা fork()-করা process), আর তার সাথে যোগ হয়েছে pipe I/O-র নিজস্ব syscall খরচ। শুধু raw switch খরচ বের করতে taskset দিয়ে একই core-এ বেঁধে দেখুন — সংখ্যা বদলাবে (নিচের build-এ এটাই পরিমাপ করা হবে)।

Thread-এর সাথে তুলনা করুন (pthread ব্যবহার করে একই টেস্ট লিখলে) — সাধারণত কম সময় লাগবে, কারণ কোনো CR3 বদল, কোনো TLB flush নেই।

এটা কী প্রমাণ করে

Context switch-এর খরচ একটা বিমূর্ত সংখ্যা নয় — দুইটা process পালাক্রমে বার্তা পাঠিয়ে সরাসরি মাপা যায়।

EXPERIMENT

Voluntary বনাম involuntary — নিজের সিস্টেমে দেখুন

Linux· ১০ মিনিট
# CPU-bound: একটা busy loop
python3 -c "
while True: pass
" &
CPU_PID=$!
sleep 2
grep ctxt_switches /proc/$CPU_PID/status
kill $CPU_PID

# I/O-bound: বারবার sleep (স্বেচ্ছায় ছেড়ে দেওয়া)
python3 -c "
import time
while True: time.sleep(0.001)
" &
IO_PID=$!
sleep 2
grep ctxt_switches /proc/$IO_PID/status
kill $IO_PID

সাধারণ ফলাফল:

# CPU-bound
voluntary_ctxt_switches:      3
nonvoluntary_ctxt_switches:   412

# I/O-bound
voluntary_ctxt_switches:      1847
nonvoluntary_ctxt_switches:   9

CPU-bound loop প্রায় কখনো নিজে থেকে CPU ছাড়ে না (voluntary কম) — scheduler-কেই জোর করে সরাতে হয় (involuntary বেশি)। sleep-নির্ভর loop প্রতিবার নিজে থেকেই ঘুমাতে যায় (voluntary বেশি) — scheduler-এর জোর করার দরকারই পড়ে না।

perf stat -e context-switches,cpu-migrations,cache-misses \
  python3 -c "sum(i*i for i in range(10_000_000))"

perf stat একই কমান্ডে context-switch এবং cache-miss একসাথে দেখাবে — সেই connection যা এই লেসনের মূল বিষয়।

এটা কী প্রমাণ করে

একটা CPU-bound আর একটা I/O-bound প্রোগ্রামের context-switch প্যাটার্ন সম্পূর্ণ ভিন্ন, আর এটা /proc থেকে সরাসরি পড়া যায়।

নিজে বানান

BUILD IT

Same-core বনাম cross-core — cache প্রভাব মাপা

C · ●●●●○
  1. দুইটা thread বানান যারা pipe দিয়ে ping-pong করে (আগের experiment-এর মতো)
  2. sched_setaffinity() দিয়ে দুইটা thread-কে একই core-এ বাঁধুন, সময় মাপুন
  3. তারপর ভিন্ন core-এ বাঁধুন, আবার মাপুন
  4. পার্থক্যটা L2/L3 cache শেয়ারিং দিয়ে ব্যাখ্যা করুন
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
#include <sched.h>
#include <unistd.h>

#define N 500000
static int pipe1[2], pipe2[2];

static void pin_to_core(int core) {
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(core, &set);
    pthread_setaffinity_np(pthread_self(), sizeof set, &set);
}

static void *responder(void *arg) {
    pin_to_core(*(int *)arg);
    char buf;
    for (int i = 0; i < N; i++) {
        read(pipe1[0], &buf, 1);
        write(pipe2[1], &buf, 1);
    }
    return NULL;
}

static double run_test(int core_a, int core_b) {
    pipe(pipe1); pipe(pipe2);
    pthread_t t;
    pthread_create(&t, NULL, responder, &core_b);

    pin_to_core(core_a);
    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);

    char buf = 'x';
    for (int i = 0; i < N; i++) {
        write(pipe1[1], &buf, 1);
        read(pipe2[0], &buf, 1);
    }

    clock_gettime(CLOCK_MONOTONIC, &t1);
    pthread_join(t, NULL);
    close(pipe1[0]); close(pipe1[1]);
    close(pipe2[0]); close(pipe2[1]);

    return ((t1.tv_sec - t0.tv_sec) * 1e9 +
            (t1.tv_nsec - t0.tv_nsec)) / (N * 2.0);
}

int main(void) {
    long ncpu = sysconf(_SC_NPROCESSORS_ONLN);
    printf("উপলব্ধ core: %ld\n\n", ncpu);
    if (ncpu < 2) { printf("অন্তত ২টা core দরকার\n"); return 1; }

    double same = run_test(0, 0);
    printf("একই core (0, 0):    %.0f ns/switch\n", same);

    double cross = run_test(0, 1);
    printf("ভিন্ন core (0, 1):   %.0f ns/switch\n", cross);

    printf("\nঅনুপাত: %.2f×\n", cross / same);
    return 0;
}
gcc -O2 -pthread -o affinity affinity.c && ./affinity

সাধারণ ফলাফল (মেশিন ও cache topology-ভেদে আলাদা হবে):

উপলব্ধ core: 8

একই core (0, 0):    3241 ns/switch
ভিন্ন core (0, 1):   5872 ns/switch

অনুপাত: 1.81×

একই core-এ দুই thread বাঁধা দ্রুততর এই নির্দিষ্ট benchmark-এ — যদিও সেটা counter-intuitive শোনায় (একই core মানে তো সমান্তরাল চলতে পারছে না?)। কারণ: pipe ping-pong-এ যেভাবেই হোক প্রতিটা মুহূর্তে একটাই thread সক্রিয় (অন্যটা read()-এ block করে আছে), তাই সমান্তরালতার কোনো সুবিধা নেই — কিন্তু একই core মানে L1/L2 cache শেয়ার্ড, তাই এক thread-এর ছোঁয়া ডেটা অন্য thread-এর জন্যও “warm” থাকে।

Cross-core-এ প্রতিটা thread-এর নিজস্ব L1/L2 (সাধারণত), শুধু L3 শেয়ার্ড (topology-ভেদে) — তাই প্রতিবার switch-এ cache miss-এর সম্ভাবনা বেশি।

বাস্তব সিস্টেমে

Context switching যেখানে সরাসরি সিদ্ধান্ত নির্ধারণ করে

Database connection pooling। প্রতিটা query-র জন্য নতুন thread বানানোর বদলে একটা fixed pool রাখা হয় — কারণ thread তৈরি ও ধ্বংসের খরচ, আর তাদের মধ্যে বারবার switch করার খরচ, সরাসরি latency যোগ করে। PostgreSQL-এর max_connections আর connection pooler (PgBouncer)-এর অস্তিত্বই এই সমস্যার সমাধান।

নেটওয়ার্ক interrupt coalescing। যদি প্রতিটা network packet একটা আলাদা interrupt (এবং সম্ভাব্য context switch) তৈরি করত, উচ্চ-গতির লিঙ্কে CPU শুধু switching-এই সময় কাটাত। NAPI (লেসন ২২-এ বিস্তারিত) একাধিক packet ব্যাচে প্রক্রিয়া করে ঠিক এই কারণে।

Thread pool sizing। একটা web server-এ থ্রেড সংখ্যা core সংখ্যার চেয়ে বেশি বাড়ালে এক পর্যায়ে throughput কমতে শুরু করে — কারণ বাড়তি thread মানে বাড়তি context switch, বাড়তি cache pollution, কোনো বাস্তব সমান্তরালতা ছাড়াই (core সীমিত)।

Real-time audio/video-এ CPU pinning। Latency-sensitive অ্যাপ্লিকেশন প্রায়ই নির্দিষ্ট core isolcpus দিয়ে সাধারণ scheduler থেকে সরিয়ে রাখে, যাতে সেই core-এ শুধু একটা thread-ই চলে — involuntary switch শূন্যে নামিয়ে predictable latency পাওয়া যায়।

Kubernetes CPU manager-এর “static” policy। Latency-critical pod-কে নির্দিষ্ট, exclusive core বরাদ্দ করা যায় (cpuset cgroup দিয়ে, লেসন ২৯-এ বিস্তারিত) — ঠিক এই context-switch ও cache-locality সমস্যার সরাসরি সমাধান production স্তরে।

যে ভুলগুলো সবাই করে

“Context switch-এর খরচ প্রধানত register save/restore-এর জন্য।”

Register save/restore (direct cost) সাধারণত মোট খরচের একটা ছোট অংশ। বেশিরভাগ খরচ indirect — cold cache, TLB miss, branch misprediction — যা switch-এর ঠিক পরে অনেকগুলো instruction জুড়ে ছড়িয়ে থাকে, একটামাত্র syscall-এ ধরা পড়ে না।

২০০৭-এর “Quantifying the Cost of Context Switch” পেপারে পরিমাপ করা হয়েছিল যে indirect cost, working set-এর আকার অনুযায়ী, direct cost-এর কয়েকগুণ থেকে কয়েকশো গুণ পর্যন্ত হতে পারে।

এই ভুল ধারণার ব্যবহারিক পরিণতি: কেউ ভাবতে পারে “context switch সংখ্যা কমানো গুরুত্বপূর্ণ নয়, কারণ প্রতিটা তো মাত্র কয়েক microsecond”। বাস্তবে ঘন ঘন switch হওয়া একটা workload-এর প্রকৃত throughput নাটকীয়ভাবে কমাতে পারে, শুধু cache-এর ক্রমাগত ঠান্ডা হয়ে যাওয়ার কারণে।

“একই process-এর দুইটা thread-এর মধ্যে switch-এ কোনো TLB প্রভাব নেই।”

CR3 না বদলানোয় flush হয় না, কিন্তু TLB-র উপর প্রভাব সম্পূর্ণ শূন্য নয়।

দুইটা thread যদি ভিন্ন memory অঞ্চলে কাজ করে (একটা thread heap-এর একটা অংশ ব্যবহার করছে, আরেকটা সম্পূর্ণ ভিন্ন অংশ), TLB-তে capacity pressure তৈরি হয় — সীমিত সংখ্যক entry-র জন্য দুই thread-এর ভিন্ন working set প্রতিযোগিতা করে, একে অপরের entry উৎখাত করতে পারে।

এটা flush-এর মতো “সব মুছে যাওয়া” নয়, কিন্তু effective TLB reach কমে যায় যদি thread-গুলোর মোট working set একটা single thread-এর তুলনায় বড় হয়।

পার্থক্যটা মাত্রার: process switch-এ TLB সম্পূর্ণ খালি (worst case)। Thread switch-এ TLB আংশিকভাবে চাপে (working set নির্ভর)। দুটোই zero cost নয়, কিন্তু thread switch স্পষ্টতই সস্তা।

“বেশি core মানে বেশি thread চালালে সবসময় দ্রুত হবে।”

Thread সংখ্যা core সংখ্যা ছাড়ালে, বাড়তি thread শুধু বাড়তি context switch তৈরি করে — কোনো বাড়তি সমান্তরাল কাজ হয় না (এক মুহূর্তে যতগুলো core আছে ততগুলো thread-ই একসাথে “সত্যিকার” সমান্তরালে চলতে পারে)।

CPU-bound workload-এ thread সংখ্যা core সংখ্যা-র কাছাকাছি রাখাই সাধারণত সর্বোত্তম — বেশি করলে শুধু cache pollution আর scheduler overhead বাড়ে, throughput কমে যেতে পারে।

ব্যতিক্রম — I/O-bound workload। যদি thread প্রায়ই block করে (I/O-র জন্য অপেক্ষা), তখন core সংখ্যার চেয়ে বেশি thread থাকাই স্বাভাবিক — যখন একটা block করে, আরেকটা CPU ব্যবহার করতে পারে। এটাই কেন web server-এর thread pool সাধারণত core সংখ্যার কয়েকগুণ বড় হয় (I/O wait-এর অনুপাত অনুযায়ী)।

নিয়ম: CPU-bound হলে threads ≈ cores; I/O-bound হলে threads ≈ cores / (1 − I/O_wait_fraction) — যদিও বাস্তবে এটা measure করেই ঠিক করা উচিত, সূত্র দিয়ে অনুমান নয়।

“Voluntary context switch 'ভালো' আর involuntary 'খারাপ'।”

দুটোই স্বাভাবিক আচরণের অংশ, কোনোটাই নিজে থেকে সমস্যা নয়।

Voluntary switch বেশি হওয়া মানে থ্রেড প্রায়ই block করছে — এটা একটা I/O-bound বা lock-heavy প্রোগ্রামের স্বাভাবিক লক্ষণ, খারাপ কিছু নয়। একটা database server-এর worker thread প্রায়ই disk বা network-এর জন্য অপেক্ষা করবে, voluntary switch বেশি হবে — এটা প্রত্যাশিত।

Involuntary switch বেশি হওয়াও নিজে থেকে সমস্যা নয় — একটা CPU-bound batch job (video encoding, scientific computation) স্বাভাবিকভাবেই বেশি involuntary switch পাবে, কারণ সে কখনো নিজে থেকে CPU ছাড়ে না, scheduler-কে quantum শেষে জোর করে সরাতে হয়।

যা আসলে সমস্যার লক্ষণ: অপ্রত্যাশিত প্যাটার্ন — যেমন একটা I/O-bound সার্ভিসে হঠাৎ involuntary switch বেড়ে যাওয়া (হয়তো CPU contention বেড়েছে, অন্য কোনো process বেশি CPU নিচ্ছে), বা একটা CPU-bound job-এ হঠাৎ voluntary switch বেড়ে যাওয়া (হয়তো একটা lock contention তৈরি হয়েছে যা আগে ছিল না)। এই সংখ্যাগুলো বেসলাইনের সাথে তুলনা করে দেখা দরকার, absolute “ভালো/খারাপ” থ্রেশহোল্ড দিয়ে নয়।

বুঝেছেন কি না দেখুন

1

কেন switch_to()-এর মতো kernel code assembly-তে লেখা হয়, C-তে নয় — যখন kernel-এর প্রায় বাকি সবকিছুই C-তে লেখা?

যুক্তি

কারণ এই কোডটা এমন একটা কাজ করে যা C-এর abstraction model-এ প্রকাশই করা যায় না: চলমান function-এর নিজস্ব stack pointer-কে মাঝপথে অন্য একটা stack-এ পাল্টে ফেলা, ঠিক সেই কোড চালু থাকা অবস্থাতেই।

C-তে প্রতিটা function call একটা স্ট্যাক-based মডেল অনুসরণ করে — compiler ধরে নেয় call করলে একটা নতুন stack frame push হবে, ret করলে সেটা pop হবে, আর পুরো সময় stack pointer একটাই ধারাবাহিক জিনিস নির্দেশ করবে।

switch_to()-এ কিন্তু:

১. বর্তমান thread A-র সব register (rsp সহ) সংরক্ষণ করতে হবে
২. rsp-কে thread B-র kernel stack-এ পাল্টাতে হবে
৩. এরপর "return" করতে হবে — কিন্তু এখন stack B-র!

ধাপ ২-এর পরে function-টা প্রযুক্তিগতভাবে অন্য একটা call stack-এ চলছে, যেটা তার আসল caller-এর সাথে কোনো সম্পর্কই নেই। C compiler এই ধরনের manipulation নিরাপদে জেনারেট করতে পারে না — সে কম্পাইল-টাইমে ধরে নেয় stack একটামাত্র ধারাবাহিক জিনিস, আর optimizer এই ধারণার উপর নির্ভর করে অনেক সিদ্ধান্ত নেয় (register allocation, inlining)।

সমাধান: এই সংকীর্ণ, hardware-এর একদম কাছের অংশটুকু হাতে assembly-তে লেখা, যেখানে প্রোগ্রামার নিজে নিশ্চিত করে প্রতিটা register ঠিক জায়গায় সংরক্ষিত ও পুনরুদ্ধার হচ্ছে, ঠিক ক্রমে।

একই কারণে যেসব জায়গায় assembly অপরিহার্য থাকে:

  • Boot-এর প্রথম কয়েক instruction (কোনো stack-ই নেই তখনও)
  • Interrupt/exception entry point (CPU যে ঠিক অবস্থায় ছিল সেটা নির্ভুলভাবে ধরতে হয়)
  • Atomic instruction-এর wrapper (lock cmpxchg ইত্যাদি, লেসন ২৮-এ বিস্তারিত)

এই সবগুলোই একটা common থিম শেয়ার করে: এমন মুহূর্ত যেখানে “স্বাভাবিক” execution model-এর অনুমানগুলো (একটা ধারাবাহিক stack, normal calling convention) সাময়িকভাবে সত্য নয়।

2

/proc/<pid>/status-এ একটা process দেখাচ্ছে voluntary_ctxt_switches: 50000 আর nonvoluntary_ctxt_switches: 5। এই process সম্পর্কে কী অনুমান করা যায়?

প্রয়োগ

এটা একটা প্রবলভাবে I/O-bound বা lock-wait-নির্ভর process, এবং CPU নিয়ে প্রায় কোনো প্রতিযোগিতাতেই নেই।

Voluntary switch-এর অনুপাত (৫০,০০০ : ৫ ≈ ১০,০০০ : ১) বলছে এই process প্রায় সবসময় নিজে থেকে CPU ছেড়ে দিচ্ছে — কোনো syscall (read, recv, sem_wait) block করছে, thread ঘুমিয়ে পড়ছে, scheduler-কে জোর করে সরাতে হচ্ছে না।

সম্ভাব্য বাস্তব উদাহরণ:

  • একটা network proxy যেটা বেশিরভাগ সময় connection-এর অপেক্ষায়
  • একটা database-এর worker যে বেশিরভাগ সময় disk I/O-র জন্য অপেক্ষা করছে
  • একটা message queue consumer যে নতুন বার্তার জন্য ব্লক করে আছে

Involuntary মাত্র ৫ হওয়ার মানে: এই process যতবারই CPU পেয়েছে, প্রায় সবসময় নিজে থেকেই সেটা ছেড়ে দিয়েছে quantum শেষ হওয়ার আগেই — সে কখনোই “CPU-hungry” আচরণ দেখায়নি যাতে scheduler-কে জোর করে সরাতে হতো।

এই তথ্য দিয়ে কী সিদ্ধান্ত নেওয়া যায়:

যদি এই process-এর throughput কম মনে হয়, সমস্যা প্রায় নিশ্চিতভাবে CPU-তে নয় — সমস্যা হলো process কী জন্য এত ঘন ঘন block করছে। পরের ধাপ: strace -c দিয়ে কোন syscall সবচেয়ে বেশিবার block করছে তা দেখা, অথবা perf trace দিয়ে wait time-এর breakdown বের করা। CPU profiling (perf top) এখানে ভুল হাতিয়ার — CPU-ই তো bottleneck নয়।

একটা সতর্কতা: এই দুইটা সংখ্যা শুধু সময়ের সাথে সাথে জমা হওয়া total, প্রতি-সেকেন্ড rate নয়। একটা দীর্ঘকাল ধরে চলা process-এর সংখ্যা স্বাভাবিকভাবেই বড় হবে। তুলনা করার জন্য সময়ের সাথে হারটা (per second) বের করা দরকার — দুইবার স্যাম্পল নিয়ে বিয়োগ করে।

3

আপনার একটা high-frequency trading system আছে যেখানে প্রতিটা microsecond latency গুরুত্বপূর্ণ। Context switching সংক্রান্ত কোন কোন কৌশল আপনি প্রয়োগ করবেন, আর কেন?

ডিজাইন

লক্ষ্য: latency-critical thread-এর জন্য context switch প্রায় শূন্যে নামিয়ে আনা, শুধু গড় থ্রুপুট নয় — worst-case latency-ই এখানে মুখ্য।

১. CPU isolation (isolcpus কার্নেল প্যারামিটার)।

নির্দিষ্ক কয়েকটা core বুট-টাইমেই সাধারণ scheduler-এর নাগালের বাইরে রাখা হয় — কোনো সাধারণ process সেখানে স্বয়ংক্রিয়ভাবে schedule হবে না। শুধু explicit affinity দিয়ে বসানো process-ই সেখানে চলে।

# kernel command line-এ
isolcpus=2,3

২. Exclusive core pinning (sched_setaffinity / cgroup cpuset)।

Latency-critical thread-কে একটা isolated core-এ একাই বাঁধা — সেই core-এ আর কোনো thread schedule হবে না, তাই involuntary switch গাণিতিকভাবেই শূন্য (কেউ competing নেই)।

৩. Interrupt affinity সরিয়ে দেওয়া।

Isolated core থেকে সব hardware interrupt অন্য core-এ সরিয়ে দেওয়া (/proc/irq/<n>/smp_affinity, লেসন ২২-এ বিস্তারিত) — নাহলে একটা network card interrupt সেই “isolated” core-কেও বাধাগ্রস্ত করতে পারে, যেটা এক ধরনের involuntary interruption (context switch না হলেও একই রকম ক্ষতিকর)।

৪. Busy-polling, blocking syscall নয়।

epoll_wait() বা read()-এ block করা মানে voluntary switch — thread ঘুমিয়ে যায়, আবার জাগতে হলে scheduler-কে ডাকতে হয়, যা কয়েক microsecond নিতে পারে (পুরো এই লেসনের বিষয়)। তার বদলে busy-poll loop (recv() non-blocking mode-এ বারবার চেক করা) CPU খরচ করে কিন্তু কখনো ঘুমায় না — তাই wake-up latency নেই।

while (1) {
    ssize_t n = recv(fd, buf, sizeof buf, MSG_DONTWAIT);
    if (n > 0) process(buf, n);
    /* কোনো sleep নেই — একটানা spin */
}

৫. Huge page ও memory prefault।

Page fault (এমনকি minor fault) একটা mode switch, তাই latency spike। Critical path-এর সব memory আগে থেকে touch করে রাখা (mlockall(MCL_CURRENT | MCL_FUTURE)) — কোনো demand paging না হওয়া নিশ্চিত করা।

৬. GC/runtime pause এড়ানো (যদি managed language ব্যবহার হয়)।

Java-তে low-latency GC (ZGC, Shenandoah), অথবা সম্পূর্ণভাবে C/C++/Rust-এ hot path লেখা, যেখানে কোনো runtime-নিয়ন্ত্রিত pause-ই নেই।

সংক্ষেপে দর্শনটা: স্বাভাবিক OS-এর প্রতিশ্রুতি হলো fairness আর ভালো গড় throughput — বহু process-এর মধ্যে CPU ন্যায্যভাবে ভাগ করা। HFT system এই প্রতিশ্রুতিই চায় না — তারা সরাসরি scheduler-কে “আমার পথ থেকে সরে যাও” বলে, একটা core সম্পূর্ণ দখল করে, আর বিনিময়ে predictable, ultra-low latency পায়। এটা একটা trade-off, “ভালো” প্র্যাকটিস নয় সব ক্ষেত্রে — একটা সাধারণ web server-এ এই কৌশলগুলো প্রয়োগ করলে সার্বিক সিস্টেম throughput কমে যাবে, কারণ পুরো core একটা thread-এর জন্য “নষ্ট” হচ্ছে even যখন সে কিছু করছে না।

4

আপনি দুইটা ভিন্ন benchmark চালিয়ে দেখলেন: একটাতে ১০,০০০টা context switch-এ মোট সময় লাগল ৫০ ms, আরেকটাতে একই সংখ্যক switch-এ লাগল ২০০ ms। দ্বিতীয়টা কেন ৪ গুণ ধীর হতে পারে, যেখানে switch সংখ্যা একই?

প্রয়োগ

Direct cost (register save/restore) দুই ক্ষেত্রেই প্রায় একই হওয়ার কথা — তাই পার্থক্যটা নিশ্চিতভাবে indirect cost-এ, আর তিনটা প্রধান সম্ভাবনা:

১. Working set-এর আকার ভিন্ন। যদি দ্বিতীয় benchmark-এর thread-গুলো বড় ডেটাসেটে কাজ করে (যা L1/L2 cache-এ আঁটে না), প্রতিবার switch-এর পর cache পুনরায় গরম করতে বেশি সময় লাগবে। প্রথম benchmark-এর thread হয়তো ছোট, cache-friendly কাজ করছে।

২. Process switch বনাম thread switch। যদি প্রথমটা thread-এর মধ্যে switch (একই address space, কোনো CR3 বদল নেই) আর দ্বিতীয়টা process-এর মধ্যে (প্রতিবার TLB flush), তাহলে এই পার্থক্যটাই ৪ গুণের একটা বড় অংশ ব্যাখ্যা করতে পারে — বিশেষত যদি PCID সক্রিয় না থাকে বা KPTI overhead যোগ হয়।

# পরীক্ষা করুন — PCID সক্রিয় কি না
grep pcid /proc/cpuinfo

৩. Core-এর মধ্যে distance। যদি প্রথম benchmark-এ থ্রেড একই core-এ (বা একই L2/L3 শেয়ার্ড group-এ) বাঁধা থাকে, আর দ্বিতীয়টায় দূরবর্তী core-এ (বিশেষত ভিন্ন NUMA node-এ) — আজকের BuildIt-এ আমরা ঠিক এই পার্থক্য মেপেছি, আর NUMA-এর ক্ষেত্রে পার্থক্য আরো নাটকীয় হতে পারে (cross-socket memory access আরো ধীর)।

যাচাই করার পদ্ধতি:

perf stat -e context-switches,cache-misses,cache-references \
  -e dTLB-load-misses ./benchmark1
perf stat -e context-switches,cache-misses,cache-references \
  -e dTLB-load-misses ./benchmark2

যদি cache-misses per switch দ্বিতীয়টায় বেশি হয় — working set বা locality-র সমস্যা। যদি dTLB-load-misses বেশি হয় — process switch/TLB flush-এর সমস্যা।

মূল শিক্ষা: “কতগুলো context switch হলো” এই সংখ্যা একা কখনো performance ব্যাখ্যা করে না (আজকের একটা misconception-এই এটা বলা হয়েছে) — কী ধরনের switch, কী working set নিয়ে, আর কতটা দূরত্বে (core/NUMA topology-তে) সেটাই প্রকৃত খরচ নির্ধারণ করে।

5

কেন branch predictor pollution একটা context switch-এর indirect cost হিসেবে গণ্য হয়? এটা cache miss-এর থেকে কীভাবে আলাদা?

যুক্তি

Branch predictor একটা ভিন্ন ধরনের “cache” — এটা মনে রাখে না ডেটা, মনে রাখে প্যাটার্ন। আর সেই প্যাটার্নও thread-ভেদে সম্পূর্ণ আলাদা হতে পারে।

আধুনিক CPU প্রতিটা conditional branch-এর ফলাফল অনুমান করে, আগে থেকেই সেই পথের instruction fetch ও execute শুরু করে (speculative execution, Level 3-এ বিস্তারিত)। অনুমান সঠিক হলে বিশাল গতি লাভ; ভুল হলে পুরো speculative কাজ বাতিল (pipeline flush) — কয়েক থেকে কয়েক ডজন cycle নষ্ট।

Branch predictor তার অনুমান করে সাম্প্রতিক ইতিহাসের উপর ভিত্তি করে — একটা নির্দিষ্ট branch instruction গত কয়েকবার কী করেছে (নেওয়া হয়েছে, নাকি নেওয়া হয়নি), সেটার একটা ছোট টেবিল রাখা হয় (Branch History Table, Branch Target Buffer)।

যখন context switch হয়:

Thread A-র branch history দিয়ে predictor “শিখেছিল” — ধরুন তার একটা loop প্রায় সবসময় একই দিকে যায়। Switch-এর পর Thread B চলা শুরু করে, কিন্তু predictor-এর টেবিলে এখনো A-র ইতিহাস বসে আছে। B-র branch instruction-গুলো হয়তো সম্পূর্ণ ভিন্ন আচরণ করে — predictor ভুল অনুমান করবে, যতক্ষণ না B-র নিজস্ব প্যাটার্ন দিয়ে টেবিল আবার “শেখে” (ঠিক cache warm-up-এর মতো, কিন্তু ডেটার বদলে প্যাটার্নের জন্য)।

Cache miss-এর সাথে পার্থক্য:

Cache missBranch misprediction
কী হারায়ডেটা/instruction-এর কপিআচরণের প্যাটার্ন-অনুমান
খরচমেমরি থেকে আনতে দেরি (~২০০ cycle DRAM)ভুল পথের কাজ বাতিল, সঠিক পথ আবার শুরু (~১৫-২০ cycle pipeline flush)
“Warm up”ডেটা আবার cache-এ আনাপ্যাটার্ন আবার শেখা (কয়েক পুনরাবৃত্তি লাগে)

দুটোই “state যা thread-ভেদে ভিন্ন হতে পারে, আর switch-এ পুরনো state ভুল তথ্য দেয়” — এই একই মৌলিক সমস্যার দুইটা রূপ, ভিন্ন hardware structure-এ।

ব্যবহারিক প্রভাব: এটাই আরেকটা কারণ কেন ঘন ঘন, ছোট quantum-এ switch করা (response time ভালো করার জন্য, লেসন ৮-এ আলোচিত) একটা লুকানো খরচ বহন করে — শুধু cache নয়, branch predictor-ও বারবার “ভুলে যায়” আর “আবার শেখে”। Scheduler ডিজাইনে quantum size নির্ধারণ এই সব লুকানো খরচের ভারসাম্য।

এরপর কী

পরের লেসন — CPU Scheduling

আজ আমরা দেখলাম switch করাটা কেমন খরচসাপেক্ষ — direct cost সরাসরি, indirect cost তার চেয়েও বড়। কিন্তু একটা প্রশ্ন এখনো অনুত্তরিত: scheduler কীভাবে ঠিক করে কখন, আর কাকে switch করবে?

যদি context switch সত্যিই ব্যয়বহুল হয় (আজকের experiment-এ কয়েক microsecond, cache warm-up ধরলে আরো বেশি), তাহলে কেন scheduler প্রতি কয়েক millisecond-এই একটা switch করায়? কেন প্রতিটা thread-কে অনেকক্ষণ চালিয়ে switch-এর সংখ্যা কমিয়ে দেওয়া হয় না?

উত্তরটা একটা মৌলিক trade-off-এ — response time বনাম throughput। কম switch মানে বেশি throughput (কম overhead), কিন্তু খারাপ response time (একটা interactive প্রোগ্রাম দীর্ঘক্ষণ অপেক্ষা করবে তার পালার জন্য)। বেশি switch মানে উল্টো।

পরের লেসনে আমরা এই trade-off-টা সরাসরি দেখব — FIFO, SJF, Round Robin-এর মতো ক্লাসিক scheduling policy, তাদের প্রতিটার সুবিধা-অসুবিধা, আর কেন কোনো একটা policy সব পরিস্থিতিতে সেরা নয়। একটা ছোট Python simulator লিখে বিভিন্ন policy একই workload-এ চালিয়ে তুলনা করব — turnaround time, response time, throughput — তিনটা metric যারা প্রায়ই একে অপরের বিরুদ্ধে টানাটানি করে।

আরও পড়ুন

  • Quantifying the Cost of Context Switch — Chuanpeng Li, Chen Ding, Kai Shen (ExpCS 2007) · Context switch-এর direct আর indirect cost আলাদা করে পরিমাপ করার একটা ক্লাসিক পদ্ধতি
  • Linux kernel source — kernel/sched/core.c (context_switch, switch_mm) · প্রকৃত switching code — switch_to() macro আর switch_mm() এখানে
  • Intel 64 and IA-32 Architectures SDM, Volume 3A, §4.10.1 — Process-Context Identifiers (PCID) — Intel Corporation · PCID-এর hardware সংজ্ঞা