কেস স্টাডিজ

কীভাবে R8 অ্যান্ড্রয়েডে কোটলিন কোরাউটিনকে দ্বিগুণ দ্রুততর করেছে

৭ মিনিটের পাঠ
জোনাথন স্টারাপের প্রোফাইল দেখুন আন্দ্রেই শিকভের প্রোফাইল দেখুন
Jonathan Starup এবং Andrei Shikov

AGP 9.2.0 থেকে শুরু করে, R8 বেশিরভাগ Atomic*FieldUpdater কলকে Unsafe ভ্যারিয়েন্টে অপ্টিমাইজ করে, যা সাধারণ অপারেশনগুলিতে ২ থেকে ৪ গুণ ভালো পারফর্ম করে। এটি kotlinx.atomicfu লাইব্রেরির উপর বিশেষভাবে বড় প্রভাব ফেলে, যা kotlinx.coroutines জন্য অ্যাটমিকস প্রয়োগ করে, এবং এর ফলে কো-রুটিন চালু ও বাতিল করা ২ গুণ পর্যন্ত দ্রুততর হয়। এই সুবিধাগুলো পেতে, আপনার AGP 9.2.0 বা তার উপরের সংস্করণে আপডেট করুন।

বেশিরভাগ অ্যান্ড্রয়েড অ্যাপ তাদের প্রধান ভাষা হিসেবে কোটলিন গ্রহণ করায়, অ্যাসিঙ্ক্রোনাস প্রোগ্রামিংয়ের জন্য kotlinx.coroutines একটি কার্যত স্ট্যান্ডার্ডে পরিণত হয়েছে। এই লাইব্রেরিটি কোটলিনের নিজস্ব একটি সুপরিকল্পিত এবং সুগঠিত উপায়ে কনকারেন্ট ফ্লো পরিচালনা করার পদ্ধতি প্রদান করে। জেটপ্যাক কম্পোজও এর ব্যতিক্রম ছিল না, যা পয়েন্টার ইভেন্ট, অ্যানিমেশন এবং অন্যান্য ইন্টারঅ্যাকশন পরিচালনার জন্য কো-রুটিন গ্রহণ করেছে। এই লেখাটি লেখার সময়, কম্পোজের বেশিরভাগ কনকারেন্ট এপিআই অভ্যন্তরীণভাবে suspend ফাংশন কল করে এবং আপডেটগুলো পরিচালনা করার জন্য কো-রুটিন চালু ও/অথবা বাতিল করে।

কম্পোজ টিম যখন পারফরম্যান্স নিয়ে তদন্ত শুরু করে, তখন দেখা যায় যে কম্পোজিশনের বাইরে সংঘটিত অনেক অপারেশনের ক্ষেত্রে কো-রুটিনগুলো একটি বাধা হয়ে দাঁড়ায়। উদাহরণস্বরূপ, Modifier.clickable তৈরি ও আপডেট করার জন্য ব্যয়িত সময়ের ৮০% ব্যয় হতো InteractionSource আপডেট পরিচালনাকারী অভ্যন্তরীণ কো-রুটিনগুলো চালু ও বন্ধ করতে। এই পর্যবেক্ষণগুলোর উপর ভিত্তি করে, প্রাথমিক পারফরম্যান্স সংক্রান্ত কাজের একটি বড় অংশ কো-রুটিনগুলোকে ডিফল্ট পাথ থেকে সরিয়ে দেওয়া এবং প্রয়োজন না হওয়া পর্যন্ত সেগুলোর ইনিশিয়ালাইজেশন বিলম্বিত করার উপর কেন্দ্রীভূত ছিল।

একটি কো-রুটিনের খরচ

অ্যান্ড্রয়েডে কোনো ফাংশনের অভ্যন্তরীণ আচরণ বিশ্লেষণ করার সবচেয়ে সহজ উপায় হলো একটি অ্যান্ড্রয়েড রানটাইম (ART) মেথড ট্রেস ক্যাপচার করা। ART মেথড ট্রেস হলো এমন একটি টুল যা একটি অ্যাপের এক্সিকিউশন ফ্লো রেকর্ড করে। এটি স্পষ্টভাবে দেখায় কোন কোন মেথড কল করা হচ্ছে, তাদের ক্রম এবং প্রতিটিতে কত সময় ব্যয় হচ্ছে, যা ডেভেলপারদের পারফরম্যান্সের বাধাগুলো শনাক্ত করতে সাহায্য করে। একটি খালি LaunchedEffect { } কলের ক্ষেত্রে, এটি দেখতে অনেকটা এইরকম হবে:

pic01_enhanced.png
পারফেটটো UI-তে LaunchedEffect মেথডের ট্রেস দেখা যায়।

উপরের মেথড ট্রেসটিকে তিনটি অংশে বিভক্ত করা যেতে পারে:

  • একটি নতুন কোরাউটিন শুরু করা হচ্ছে
  • কোরাউটিন শুরু করা
  • কো-রুটিনটি সম্পূর্ণ করা হচ্ছে (কারণ এটি সাথে সাথেই বেরিয়ে যায়)

LaunchedEffect বাতিল করা সাধারণ সমাপ্তির মতোই, তবে এটি একটি CancellationException ও তৈরি করে।

উপরের প্রোফাইল থেকে, একটি বিষয় যা তাৎক্ষণিকভাবে সন্দেহজনক তা হলো java.util.concurrent.AtomicReferenceFieldUpdater কে ঘন ঘন কল করা (j… লেবেলযুক্ত বেগুনি বা সবুজ বক্স)। যদিও প্রতিটি কল তুলনামূলকভাবে দ্রুত, এর পুনরাবৃত্তি উদ্বেগজনক; একাধিক আহ্বানের মধ্যে ছড়িয়ে থাকা যেকোনো উল্লেখযোগ্য ওভারহেড একত্রিত হয়ে একটি লক্ষণীয় রিগ্রেশন ঘটাতে পারে। কোনো একটি কলকে আরও কাছ থেকে দেখলে বোঝা যায় যে বেশিরভাগ সময় ব্যয় হচ্ছে... রিফ্লেকশন চেক করতে?

pic02-enhanced.png
LaunchedEffect ইনিশিয়ালাইজেশনের সময় AtomicReferenceFieldUpdater.get মেথড ট্রেসের একটি নিবিড় পর্যবেক্ষণ

কোরাউটিন প্যারেন্ট-চাইল্ড সম্পর্কের জন্য একটি লক-ফ্রি ট্রি স্ট্রাকচার প্রয়োগ করে, যা স্ট্রাকচার্ড কনকারেন্সি সম্ভব করে তোলে। দেখা যায় যে, kotlinx.atomicfu লাইব্রেরিটি একটি সুপরিচিত JVM প্রিমিটিভ, AtomicReferenceFieldUpdater ব্যবহার করে লক-ফ্রি অ্যাটমিক অপারেশনগুলো বাস্তবায়ন করে। এই আপডেটারটি রানটাইমে অ্যাটমিক অপারেশন সম্পাদনের জন্য একটি ক্লাস রেফারেন্স এবং একটি ফিল্ডের নাম ব্যবহার করে, এবং ফিল্ডটি বিদ্যমান ও অ্যাক্সেসযোগ্য কিনা তা নিশ্চিত করার জন্য এটিকে বেশ কয়েকটি রিফ্লেক্টিভ সেফটি চেক চালাতে হয়। কোরাউটিনের প্রতিটি অপারেশন (শুরু করা, সাসপেন্ড করা, বাতিল করা, সম্পন্ন করা) অন্তত একটি অ্যাটমিক অপারেশনকে কল করে, তাই এটি ধীরগতির হলে কোরাউটিনগুলো ভালোভাবে কাজ করবে না।

AtomicReferenceFieldUpdater তদন্ত করা হচ্ছে

কিন্তু এখনই বেশি কিছু বলে ফেলার দরকার নেই। AtomicReferenceFieldUpdater আসলে ১০ বছরেরও বেশি সময় ধরে JVM-এ বেশ ভালোভাবে অপ্টিমাইজ করা আছে, এবং মেথড ট্রেস এমন ওভারহেড ধরতে পারে যা VM লেভেলের অপ্টিমাইজেশন—যেমন জাস্ট-ইন-টাইম (JIT) বা অ্যাহেড-অফ-টাইম (AOT) কম্পাইলেশনের মাধ্যমে—সম্পূর্ণরূপে দূর করা হয়। পারফরম্যান্স যাচাই করার জন্য, চলুন kotlinx.atomicfu এবং java.util.concurrent.atomic এর অ্যাটমিক রেফারেন্সগুলোর মধ্যে পার্থক্য মাপার জন্য কয়েকটি বেঞ্চমার্ক লিখি।

@RunWith(AndroidJUnit4::class)
class AtomicReferenceBenchmark {
    @get:Rule
    val benchmarkRule = BenchmarkRule()
    
    private val atomicReference = java.util.concurrent.atomic.AtomicReference(false)
    private val atomicRef = kotlinx.atomicfu.atomic<Boolean>(false)
    
    @Test
    fun atomicReference_compareAndSet() {
        benchmarkRule.measureRepeated { 
            atomicReference.compareAndSet(true, false)
            atomicReference.compareAndSet(false, true)
        }
    }

     @Test
    fun atomicRef_compareAndSet() {
        benchmarkRule.measureRepeated {
            atomicRef.compareAndSet(true, false)
            atomicRef.compareAndSet(false, true)
        }
    }

    /* measuring other methods from the method traces above */
}

পিক্সেল ৫-এ এই বেঞ্চমার্কটি চালালে (ওয়ার্মআপের সময় AtomicReferenceFieldUpdater#compareAndSet JIT কম্পাইল করা নিশ্চিত করে), পিক্সেল ৫ (API 33)-এ নিম্নলিখিত ফলাফল পাওয়া যায়:

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

পরিমাপগুলো এই পার্থক্যকে নিশ্চিত করে, যেখানে kotlinx.atomicfu সংস্করণটি স্পষ্টতই প্রায় ২.৭ গুণ ধীরগতির। এটি প্রমাণ করে যে ART কোনো গোপন অপ্টিমাইজেশন করে না এবং রিফ্লেক্টিভ অ্যাক্সেস চেকগুলো রানটাইমে প্রকৃত ওভারহেড তৈরি করে।

মূল মেথড ট্রেসটি পর্যালোচনা করলে দেখা যায়, AtomicReferenceFieldUpdater দ্বারা সম্পাদিত একমাত্র অর্থপূর্ণ কাজটি হলো Unsafe.getObjectVolatile এর অভ্যন্তরীণ কল, যা প্রকৃতপক্ষে অন্তর্নিহিত অ্যাটমিক অপারেশনটি সম্পাদন করে। বেশিরভাগ ক্ষেত্রে, আপডেটার ইনিশিয়ালাইজারটি স্ট্যাটিক হয় এবং পারিপার্শ্বিক ক্লাসের কাঠামোর উপর ভিত্তি করে এটি যে সর্বদা সঠিক, তা প্রমাণ করা যায়। সুতরাং, কম্পাইলেশনের সময় AtomicReferenceFieldUpdater এর বেশিরভাগ ব্যবহার স্ট্যাটিক্যালি বিশ্লেষণ করে সেগুলোকে একটি অভ্যন্তরীণ Unsafe ভ্যারিয়েন্ট দিয়ে প্রতিস্থাপন করা যেতে পারে। কাকতালীয়ভাবে, অ্যান্ড্রয়েড বিল্ড টুলচেইনের নিজস্ব একটি অপটিমাইজিং কম্পাইলার রয়েছে যা ঠিক এই কাজটিই করতে পারে।

R8 দিয়ে অপ্টিমাইজেশন

Atomic*FieldUpdater ক্লাসগুলো সূক্ষ্ম, ডাইনামিক এবং রিফ্লেকশন-ভিত্তিক ব্যবহার সমর্থন করে, কিন্তু প্রায়শই স্ট্যাটিক্যালি সুস্পষ্ট প্যাটার্নে ব্যবহৃত হয়। এটি এর ধীর বেসলাইন পারফরম্যান্স এবং অপটিমাইজেশনের প্রয়োজনীয়তা উভয়ই ব্যাখ্যা করে। R8 একটি সম্পূর্ণ-প্রোগ্রাম অপটিমাইজিং কম্পাইলার এবং এটি রিফ্লেক্টিভ সেফটি চেকের ওভারহেড এড়িয়ে যাওয়ার জন্য সহজ প্যাটার্নগুলো ভেদ করতে বিশেষভাবে উপযুক্ত। R8, জাভা বা কোটলিন কম্পাইলারের পরে JVM বাইটকোড গ্রহণ করে, কিন্তু পাঠযোগ্যতা সহজ করার জন্য এই উদাহরণগুলো জাভা সিনট্যাক্সে উপস্থাপন করা হয়েছে। এই কারণেই AtomicReferenceFieldUpdater জন্য কোনো টাইপ আর্গুমেন্ট নেই।

class Example {
    volatile String data = "";
    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");

    void example() {
        // ...
        updater.compareAndSet(this, "", "new");
        // ...
    }
}

মূল উদাহরণটি একটি স্ট্যাটিক ফাইনাল আপডেটার তৈরি করে, যা হোল্ডার, টাইপ এবং ফিল্ডের নামের জন্য সাধারণ কনস্ট্যান্ট আর্গুমেন্ট ব্যবহার করে একটি ভলাটাইল ফিল্ড অ্যাক্সেস করে। ব্যবহৃত রিফ্লেকশনটি সম্পূর্ণ স্বচ্ছ। এটি পরিষ্কারভাবে দেখা যায় যে এই আপডেটারটি একটি বৈধ ফিল্ডকে রেফারেন্স করে এবং আপডেটারটি তৈরির সাইটের ফিল্ডটিতে বৈধ অ্যাক্সেস রয়েছে।

মূলত, Atomic*FieldUpdater হলো একটি ফিল্ড অফসেট এবং Unsafe এর কলগুলোর একটি র‍্যাপার। অপটিমাইজেশনের জন্য সবচেয়ে ভালো উপায় হলো আপডেটার ফিল্ডটিকে একটি অফসেট ফিল্ড দিয়ে এবং আপডেটার কলগুলোকে Unsafe এর কল দিয়ে প্রতিস্থাপন করা।

Atomic*FieldUpdater অপ্টিমাইজ করা

অপ্টিমাইজেশনটি তিনটি অংশে বাস্তবায়িত হয়: ইন্সট্রুমেন্টেশন, প্রতিস্থাপন এবং পরিষ্করণ।

যন্ত্রপাতি

প্রথম ধাপ হলো আপডেটার ফিল্ডের পাশাপাশি অফসেট ফিল্ড যুক্ত করা, যাতে Unsafe কলের মাধ্যমে সরাসরি অ্যাক্সেস সহজ হয়।

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))

ফিল্ডটি রিফ্লেকশনের মাধ্যমে অ্যাক্সেস করা হয়, এবং ক্লাসে ফিল্ড অফসেট বের করার জন্য Unsafe ব্যবহার করা হয়। আপনি যদি রিফ্লেকশন ভ্যালিডেশন উপেক্ষা করেন, তাহলে এই কোডটি Atomic*FieldUpdater এর অভ্যন্তরীণ কার্যপ্রণালী তুলে ধরে। এর পরিবর্তে, আপডেটারের হোল্ডার টাইপ এবং ভলাটাইল ফিল্ডের ফিল্ড টাইপ কম্পাইলারে স্ট্যাটিক্যালি ট্র্যাক করা হয়।

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

প্রতিস্থাপন

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

updater.compareAndSet(holder, expectedValue, newValue);

Atomic*FieldUpdater জন্য প্রয়োজনীয় শর্তগুলো হলো:

  • updater কি কোনো ইনস্ট্রুমেন্টেড ফিল্ড থেকে আসে? অর্থাৎ, স্ট্যাটিক অ্যানালাইসিস কি অবজেক্টটির মানকে কোনো ইনস্ট্রুমেন্টেড আপডেটারের ফিল্ড রিড পর্যন্ত ট্র্যাক করতে পারে?
  • holder কি মূলত সংজ্ঞায়িত হোল্ডার টাইপের একই ক্লাস নাকি একটি সাবক্লাস?
  • newValue কি মূলত সংজ্ঞায়িত ফিল্ড টাইপের একই ক্লাস, নাকি একটি সাবক্লাস?

যদি সমস্ত শর্ত পূরণ হয়, তাহলে কলটি কোনো রিফ্লেকশন চেক ছাড়াই একটি Unsafe কল দ্বারা প্রতিস্থাপিত হয়।

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)

এই নতুন কলটি দ্রুততর ও সরলতর, কিন্তু updater এবং holder নাল ভ্যালু পরিচালনার ক্ষেত্রে এটি মূল কলটি থেকে ভিন্ন। স্ট্যাটিক্যালি বাতিল না করা হলে, উভয়ের জন্যই নাল-চেক অন্তর্ভুক্ত করা হয়।

পরিষ্কার-পরিচ্ছন্নতা

এই পর্যায়ে, হোল্ডিং ক্লাসে মূল আপডেটার ফিল্ড এবং নতুন অফসেট ফিল্ডের সাথে এমন কল সাইটগুলোও থাকে যেগুলো এই দুটির যেকোনো একটি ব্যবহার করতে পারে। যদি কোনো কল সাইটই অপ্টিমাইজ করা না হয়ে থাকে, তাহলে অফসেট ফিল্ডটি সরিয়ে ফেলা উচিত এবং যদি সমস্ত কল সাইট অপ্টিমাইজ করা হয়ে থাকে, তাহলে আপডেটার ফিল্ডটি সরিয়ে ফেলা উচিত। উভয় ক্ষেত্রেই ইনিশিয়ালাইজিং কলটিও ডিলিট করে দেওয়া উচিত। অব্যবহৃত ফিল্ড ডিলিট করা এবং ডেড কোড অপসারণ করার কাজটি কম্পাইলারেই করা হয়ে থাকে, কিন্তু এখান থেকে ইনিশিয়ালাইজিং কোডটি অপসারণ করার জন্য আরও কিছু কৌশলের প্রয়োজন হয়।

newUpdater এবং getDeclaredField উভয় কলেরই সাইড এফেক্ট থাকতে পারে, কারণ এগুলো এক্সেপশন থ্রো করতে পারে (এবং এদের ইমপ্লিমেন্টেশনও অজানা, যেহেতু তা এপিআই ভার্সনের উপর নির্ভর করে)। এর মানে হলো, জেনেরিক অপটিমাইজেশনের মাধ্যমে এগুলোকে নিরাপদে অপসারণ করা যায় না। তাই এই পরিষ্করণ প্রক্রিয়ায় ইনস্ট্রুমেন্টেড ফিল্ডগুলোকে সুস্পষ্টভাবে বিবেচনা করার প্রয়োজন ছিল, কারণ স্ট্যাটিক্যালি জানা আছে যে সেগুলো এক্সেপশনমুক্ত।

অবশেষে, উপরে দেখানো সাধারণ আপডেটার উদাহরণটি অপ্টিমাইজেশনের পর এইরকম দেখায়:

ফলাফল

এই অপ্টিমাইজেশনগুলোর পর, kotlinx.atomicfu এবং AtomicInt/Long/ReferenceFieldUpdater এর অধিকাংশ সুস্পষ্ট ব্যবহার এখন R8 প্রয়োগকৃত AtomicReference পারফরম্যান্সের সমতুল্য। বস্তুত, কিছু বেঞ্চমার্কে এটি আরও দ্রুততর; kotlinx.atomicfu একটি কম্পাইলার প্লাগইন আছে যা atomic ইনস্ট্যান্সগুলোকে ফিল্ডের মধ্যে ইনলাইন করতে পারে, ফলে অ্যাটমিকভাবে আপডেট করা ফিল্ড তৈরি করার জন্য প্রয়োজনীয় অ্যালোকেশন কমে যায়।

এই কাজের প্রধান সুবিধাভোগী ছিল জেটপ্যাক কম্পোজ। কম্পোজ রানটাইমে বেশ কিছু মাইক্রোবেঞ্চমার্ক রয়েছে যা পারফরম্যান্সের অবনতি দ্রুত শনাক্ত করার জন্য কো-রুটিনের পারফরম্যান্স খুব নিবিড়ভাবে পর্যবেক্ষণ করে। যখন বেঞ্চমার্কগুলো R8-এর নতুন সংস্করণে আপডেট করা হলো, আমরা LaunchedEffect এ কো-রুটিন চালু ও বাতিল করার ক্ষেত্রে দ্বিগুণ উন্নতি লক্ষ্য করলাম!

pic03_enhanced.png
LaunchedEffect-এ কো-রুটিন শুরু এবং বাতিল করতে লাগা সময় প্রদর্শনকারী বেঞ্চমার্ক গ্রাফ (সময় যত কম, ফলাফল তত ভালো)। গ্রাফের পরিবর্তনটি একটি R8 আপডেটের সাথে সম্পর্কিত, যা ২ গুণ উন্নতি প্রদর্শন করে।

তাছাড়া, ART টিম এই অপটিমাইজেশনগুলো VM লেভেলে নেটিভভাবে প্রয়োগ করছে। যদি আপনার অ্যাপটি API 36 টার্গেট করে এবং অ্যান্ড্রয়েডের সাম্প্রতিক কোনো সংস্করণে চলে, তাহলে এমন হতে পারে যে আপনার ডিভাইসটি ইতিমধ্যেই একইভাবে কো-রুটিন অপটিমাইজ করছে। ART-এর সাম্প্রতিক সংস্করণগুলোতে JIT আপডেটের পর উপরের কো-রুটিন বেঞ্চমার্কগুলোতে পারফরম্যান্সে প্রায় ১৫% উন্নতি দেখা গেছে।

AGP 9.2.0-তে আপগ্রেড করার সময় অথবা সরাসরি R8 9.2.0 ব্যবহার করার মাধ্যমে আপনার অ্যাপটি ডিফল্টরূপে এই অপ্টিমাইজেশনটি পাবে। আরও তথ্যের জন্য, D8 dexer এবং R8 shrinker দেখুন।

লিখেছেন:
পড়তে থাকুন