۲۷ - ۸وِکتور یا Vector

ساختار داده ِ بعدی که نگاه می‌کنیم، توی ‏‎containers‎‏ نیست؛ توی کتابخونه ِ خودش به اسمِ ‏‎vector‎‏ ِه. اگه به اون لینک برین، می‌بینین که بردارها رو "آرایه‌های بهینه" معرفی کرده. ما سراغ آرایه‌ها، یا تایپِ ‏‎Array‎‏ ِ هسکل نمیریم.

کسی زیاد از آرایه‌ها (یا دقیق‌تر بگیم، ‏‎Array‎‏ ِ هسکل) استفاده نمی‌کنه. تقریباً همیشه بجای آرایه از ‏‎Vector‎‏ استفاده میشه. تعریفِ تایپِ ‏‎Vector‎‏ ِ معمولی از این قراره:

-- | بردارهای جعبه‌شده، به
-- | ‎‏بُرِش زدنِ بهینه کمک می‌کنن.‏‎
data Vector a =
     Vector {-# UNPACK #-} !Int
            {-# UNPACK #-} !Int
            {-# UNPACK #-} !(Array a)
     deriving ( Typeable )

‏‎Vector‎‏ انواعِ زیادی داره. بعضی‌هاشون رو بخوایم بگیم: جعبه‌شده، جعبه‌نشده، تغییرناپذیر، تغییرپذیر، و بردارهای انبارشدنی، اما معمولاً فقط از همون نسخه‌ی معمولی‌ش که این بالا نوشتیم استفاده میشه. تو این فصل یه بخش به بردارهای تغییرپذیر اختصاص دادیم. منظور از جعبه‌شده اینه که بردار می‌تونه به هر نوع‌داده‌ای که بخواین اشاره کنه؛ جعبه‌نشده مقدارهای خام رو ارائه میده، بدون اشاره‌ی اشاره‌گر* و می‌تونه خیلی در حافظه صرفه‌جویی کنه، اما فقط محدود به تایپ‌های ‏‎Bool‎‏، ‏‎Char‎‏، ‏‎Double‎‏، ‏‎Float‎‏، ‏‎Int‎‏، ‏‎Word‎‏، و توپل‌های مقادیرِ جعبه‌نشده هست. از اونجا که نیوتایپ لیفت‌نشده هست و هیچ اشاره‌ی اشاره‌گری نمی‌سازه، هر نیوتایپی که شاملِ یه تایپِ جعبه‌نشدنی باشه رو میشه از جعبه درآورد.

*

این کتاب جای صحبت راجع به اشاره‌گرها نیست. اما بطورِ خلاصه، اشاره‌گرها به یه چیزی توی حافظه اشاره می‌کنن، تا اینکه خودشون اون چیز باشن.

چه زمانی در هسکل ‏‎Vector‎‏ لازم میشه؟

زمانی ‏‎Vector‎‏ می‌خواین که:

  • برای داده‌ای که باهاش کار می‌کنین، بازدهیِ حافظه نزدیک به ماکسیممِ نظری لازم دارین؛

  • دسترسی به داده تقریباً فقط محدود به اندیس‌گیری با یه مقدارِ ‏‎Int‎‏ ِه؛

  • می‌خواین زمانِ لازم برای دسترسی به هر کدوم از المان‌های ساختار داده یکسان باشه؛ و یا،

  • می‌خواین یک بار یه ‏‎Vector‎‏ بسازین و چندین بار بخونین‌ش (یا می‌خواین از یه ‏‎Vector‎‏ ِ تغییرپذیر برای به‌روزرسانی‌های بهینه استفاده کنین).

    اون برش زدن که گفتیم چی بود؟

    این کامنت در تعریفِ ‏‎Vector‎‏ یادتون‌ه؟

    -- | بردارهای جعبه‌شده، به
    -- | ‎‏بُرِش زدنِ بهینه کمک می‌کنن.‏
    
     -- :م. متنِ اصلی
    -- | Boxed vectors, supporting
    -- | efficient slicing.

    برش زدن یعنی بریدنِ یه تیکه از آرایه، یا ساختن یه زیر-آرایه. تایپِ ‏‎Vector‎‏ طوری طراحی شده که برش زدن‌ِش کم هزینه باشه. این مثال رو در نظر بگیرین:

    module Main where
    
    import Criterion.Main 
    import qualified Data.Vector as V
    
    slice :: Int -> Int -> [a] -> [a]
    slice from len xs = 
      take len (drop from xs)
    
    l :: [Int]
    l = [1..1000]
    
    v :: V.Vector Int
    v = V.fromList [1..1000]
    
    main :: IO ()
    main = defaultMain
      [ bench "slicing list" $
          whnf (head . slice 100 900) l
      , bench "slicing vector" $
          whnf (V.head . V.slice 100 900) v
      ]

    تو نتیجه‌ی این بنچمارک، ‏‎Vector‎‏ باید سریع‌تر از لیست باشه. چرا؟ چون تمامِ کاری که موقعِ ساختن ِ ‏‎Vector‎‏ ِ جدید (با ‏‎V.slice‎‏) باید انجام میشد، این بود:

    -- Data.Vector از
    
    instance G.Vector Vector a where
      -- بقیه‌ی متودها حذف شدن
      {-# INLINE basicUnsafeSlice #-}
      basicUnsafeSlice j n (Vector i _ arr) =
        Vector (i+j) n arr

    چیزی که ‏‎Vector‎‏ رو از این لحاظ بهتر از لیست و ‏‎Array‎‏ می‌کنه، اینه که موقع ساختنِ یه بُرش یا نما از یه ‏‎Vector‎‏ ِ دیگه، نیازی نیست به اندازه‌ی اون دوتا، داده ِ جدید بسازه. فقط یه پوشش ِ جدید اطرافِ آرایه ِ اصلی، با یه اندیس و آفست ِ جدید (با ارجاع به ‏‎Array‎‏ ِ اصلی) برمی‌گردونه. همین کار با ‏‎Array‎‏ ِ معمولی یا یه لیست، ملزم به کپی کردنِ داده‌ها ِ بیشتری می‌بود. با همین موذی‌گری‌ها و از زیر کار در رفتن‌ها میشه سرعت رو زیاد کرد.

    به‌روزرسانیِ بردارها

    بردارهای پایا در به‌روزرسانی‌های مداوم خیلی خوب عمل نمی‌کنن، اما مواردی پیش میاد که ممکنه غافلگیرکننده باشن. یکی از این موارد، جوشِش ِه.* منظور از جوشش (یا جوششِ حلقه) اینه که کامپایلر می‌تونه در یک پیمایش، چندین‌تا حلقه رو به یک حلقه ِ گنده جوش بده:

    module Main where
    
    import Criterion.Main 
    import qualified Data.Vector as V
    
    testV' :: Int -> V.Vector Int
    testV' n =
      V.map (+n) $ V.map (+n) $
        V.map (+n) $ V.map (+n)
        (V.fromList [1..10000]) 
    
    testV :: Int -> V.Vector Int
    testV n =
      V.map ( (+n) . (+n)
            . (+n) . (+n) )
            (V.fromList [1..10000]) 
    
    main :: IO ()
    main = defaultMain
      [ bench "vector map prefused" $
          whnf testV 9998
      , bench "vector map will be fused" $
          whnf testV' 9998
      ]

    توی کتابخونه ِ ‏‎vector‎‏، جوششِ حلقه بصورت آماده تعریف شده، پس در خیلی از موارد (مثل نگاشت کردن)، فقط به خاطر اینکه ۴ بار نگاشت کردین چهارتا بردار درست نمی‌کنه. با استفاده از GHC RULES (م. یا قوانینِ GHC)، کُدِ ‏‎testV'‎‏ به کُدی که در ‏‎testV‎‏ می‌بینین تبدیل میشه. جا داره بگیم یکی از دلایلی که این بهینه‌سازی مطمئن‌ه، اینه که به خاطر تایپ‌ها، میدونیم چه کُدی اثر اجرا می‌کنه و چه کُدی نمی‌کنه.

    با همه‌ی اینها، جوششِ حلقه نوش‌دارو نیست؛ ممکنه شرایطی پیش بیاد که بخواین بعضی المان‌ها رو یکی‌یکی تغییر بدین. کلاً راه‌های بهینه‌تری هم برای به‌روزرسانیِ بردارها وجود دارن. اینجا از اوپراتور ِ ‏‎(//)‎‏ از کتابخونه ِ ‏‎vector‎‏ استفاده می‌کنیم. با این اوپراتورِ به‌روزرسانیِ دسته‌ای میشه هربار چندین المانِ یه بردار رو تغییر داد:

    module Main where
    
    import Criterion.Main
    import Data.Vector ((//))
    import qualified Data.Vector as V
    
    vec :: V.Vector Int
    vec = V.fromList [1..10000]
    
    slow :: Int -> V.Vector Int
    slow n = go n vec
      where go 0 v = v
            go n v = go (n-1) (v // [(n, 0)])
    
    batchList :: Int -> V.Vector Int
    batchList n = vec // updates
      where updates =
              fmap (\n -> (n, 0)) [0..n]
    
    main :: IO ()
    main = defaultMain
      [ bench "slow" $ whnf slow 9998
      , bench "batch list" $
          whnf batchList 9998
      ]

    مشکلِ مثال اول اینه که از API ِ دسته‌ای استفاده کرده اما... دسته‌ای نیست. اینکه اول همه‌ی لیستِ به‌روزرسانی‌ها ساخته، و بعد به تابعِ ‏‎(//)‎‏ داده بشه، خیلی کم هزینه‌تره (در تست‌های ما بین ۵۰۰ تا ۱۰۰۰ برابر ارزون‌تر). اگه از تابعِ به‌روزرسانی که با بردار ِ به‌روزرسانی‌ها کار می‌کنه استفاده کنیم، از این هم سریع‌تر میشه:

    batchVector :: Int -> V.Vector Int
    batchVector n = V.unsafeUpdate vec updates
      where updates =
              fmap (\n -> (n, 0))
              (V.fromList [0..n])

    نتیجه‌ی بنچمارکِ این نسخه باید حدود ۱٫۴ برابر سریع‌تر باشه. ولی ما حریص‌تر از این حرف‌هاییم.

    ‏‎Vector‎‏‌های تغییرپذیر

    ”موریا! موریا! شگفتی سرزمین شمال. چنان در اعماق آن پیش رفتیم که همانا هراس بی‌نام را بیدار کردیم.“
    —گلویین، یارانِ حلقه

    اگه سریع‌تر بخوایم چطور؟ با اینکه خیلی‌ها واقف نیستن، اگه بخوایم، تو هسکل میشه از مزایای به‌روزرسانیِ دَرجا هم استفاده کرد. چیزی که هسکل رو متمایز می‌کنه اینه که این کار رو میشه بدونِ لطمه به شفافیت مرجع یا مشخصاتِ معادلاتیِ خیلی خوبی که بیانیه‌ها دارن انجام داد. اول نشون میدیم چطور باید انجام بشه، بعد توضیح میدیم که چطور طراحی شده تا قابلِ پیش‌بینی رفتار کنه.

    مثال از راه رسید:

    module Main where
    
    import Control.Monad.Primitive
    import Control.Monad.ST
    import Criterion.Main
    import qualified Data.Vector as V
    import qualified Data.Vector.Mutable as MV
    import qualified
           Data.Vector.Generic.Mutable as GM
    
    mutableUpdateIO
      :: Int
      -> IO (MV.MVector RealWorld Int)
    mutableUpdateIO n = do
      mvec <- GM.new (n+1)
      go n mvec
      where go 0 v = return v
            go n v =
              (MV.write v n 0) >> go (n-1) v
    
    mutableUpdateST :: Int -> V.Vector Int
    mutableUpdateST n = runST $ do
      mvec <- GM.new (n+1)
      go n mvec
      where go 0 v = V.freeze v
            go n v =
              (MV.write v n 0) >> go (n-1) v
    
    main :: IO ()
    main = defaultMain
      [ bench "mutable IO vector"
        $ whnfIO (mutableUpdateIO 9998)
      , bench "mutable ST vector"
        $ whnf mutableUpdateST 9998
      ]

    به ذره جلوتر ‏‎runST‎‏ رو توضیح میدیم. فعلاً حواسمون رو بیاریم روی سرعت: با تست‌های ما، نسخه‌ی ‏‎IO‎‏ ِ تغییرپذیر تقریباً ۷۰۰۰ برابر سریع‌تر از اون حلقه ِ دسته‌نشده ِ اصلی‌مون شد. نسخه‌ی ‏‎ST‎‏ تقریباً ۱٫۵ برابر کندتر از نسخه‌ی ‏‎IO‎‏ شد، ولی باز خیلی سریع بود. زمانِ اضافی که ‏‎ST‎‏ صرف کرد، به خاطر فریزکردنِ بردار ِ تغییرپذیر به یه بردار ِ معمولی بود. ما اینجا ‏‎ST‎‏ رو کامل توضیح نمیدیم، اما همونطور که خواهید دید، به دردِ وقت‌هایی می‌خوره که می‌خواین یه چیزی رو موقتاً تغییرپذیر کنین و مطمئن باشین که هیچ ارجاع ِ تغییرپذیری به خارج از موند ِ ‏‎ST‎‏ افشا نمیشه.

    این جدول، نتایجی که ما با چندتا از عملیات‌های برداری گرفتیم رو نشون میده:

    نسخه‌ی عملیات (اسم تابع)میکروثانیه
    ‏‎slow‎‏133,600
    ‏‎batchList‎‏244
    ‏‎batchVector‎‏176
    ‏‎mutableUpdateST‎‏32
    ‏‎mutableUpdateIO‎‏19

    بیشترین پیشرفت به خاطر حذف کارهای احمقانه بود. فقط وقت‌هایی که می‌دونین واجبه، به استفاده از تغییرپذیری با ‏‎IO‎‏ یا ‏‎ST‎‏ متوصل شین. دقت کنین که چون این تست تقریباً ۱۰۰۰۰ اندیس در یک بردار رو به‌روزرسانی می‌کرد، وقتی از تغییرپذیری استفاده کردیم حدوداً هر به‌روزرسانی ۱٫۹ نانوثانیه، و وقتی بصورت دسته‌ای با یه بردار ِ پایا انجام دادیم، تقریباً ۱۷٫۶ نانوثانیه صرفِ هر به‌روزرسانی شد.

    توضیح اجمالی از ‏‎ST Monad‎‏

    ‏‎ST‎‏ رو میشه نسخه‌ی تغییرپذیر ِ موند ِ ‏‎State‎‏ ِ اکید تعریف کرد. یا از یه زاویه‌ی دیگه، میشه گفت یه ‏‎IO‎‏ ایه که صرفاً محدود به تغییرپذیری‌ها ایه که امنیت‌شون تضمین‌شده‌ست.

    چطوری امن‌اند؟ ‏‎ST‎‏ به قاعده‌ی فلسفیِ درخت توی جنگل* اتکا داره. ایده‌ی پشتِ این درکِ "اخلاقاً بی‌اثر" در مقاله‌ی ریسه‌های اجراییِ حالتِ تابعی و تنبل معرفی شد. اول داده رو آزاد می‌کنه، تغییرش میده، بعد دوباره فریزِش می‌کنه تا دیگه نتونه تغییر کنه. پس می‌تونه در عینِ حفظِ شفافیت مرجع، تغییر کنه.

    *

    م. "اگه درختی توی جنگل بیوفته، اما کسی اون اطراف نباشه که بشنوه، آیا صدایی میده؟"

    ‏‎Lazy Functional State Threads; John Launchbury and Simon Peyton Jones‎‏

    اگه بهینه‌کننده ترتیبِ کُدی که داده رو تغییر میده تغییر بده، یا به هر نحو دیگه‌ای با اون کُد بازی بشه، دیگه ‏‎ST‎‏ درست کار نمی‌کنه (درست مثلِ ‏‎IO‎‏). پس باید یه چیزی پشتِ‌پرده‌ی تایپ باشه که نذاره GHC همه چیز رو به‌هَم بزنه. تایپِ ‏‎ST‎‏ رو بررسی کنیم ببینیم:

    Prelude> import Control.Monad.ST
    Prelude> :info ST
    newtype role ST nominal representational
    newtype ST s a =
      GHC.ST.ST (GHC.ST.STRep s a)
    ...
    Prelude> import GHC.ST
    Prelude> :info STRep
    type STRep s a =
         GHC.Prim.State# s
      -> (# GHC.Prim.State# s, a #)

    یه کم زشته، اما به خاطرِ همه‌ی اون GHC Core هایی که فصلِ قبل دیدین دیگه نباید سورپرایز بشین! چیزی که اینجا مهمه اینه که ‏‎s‎‏ تایپ‌ش رو از اون چیزی می‌گیره که دارین تغییر میدین، اما هیچ شاهد ِ سطحِ جمله‌ای نداره. پس اینجا ‏‎State Monad‎‏ پاک‌شدنی‌ه؛ می‌تونه این فرایندِ موتاسیون رو ایزوله کنه و بعد آب شه بِره.

    خیلی مهمه که متوجه باشین اون ‏‎s‎‏، حالتی که تغییر میدین نیست. تغییری که رخ میده، اثرِ جانبی ِ ناشی از ورود به بستار‌ها ایه که اثر رو پیاده می‌کنن. بر حسبِ اتفاق، این موندِ تغییردهنده‌ی حالت – که اکید و لیفت‌نشده هست – طوری کدِتون رو ساختار میده که ترتیبِ محاسبات و اثراتِ مرتبط‌شون رو، همونطور که باید حفظ می‌کنه.

    منظورمون از بستار که گفتیم، بیانیه‌های لاندا بود.* ورود به هر لاندا، اثرات‌ِش رو اجرا می‌کنه. به خاطرِ مهم بودنِ ترتیب در اجرای اثرها، اثرهای یه سری از لانداها دسته نمیشن (هر بیانیه‌ی جدید ممکنه وابسته به اثراتِ قبلی باشه). اول اثرها اجرا میشن، بعد اگه قرار به استفاده از مقدارِ حاصل از محاسبات باشه، اون مقدار هم حساب میشه.

    *

    صدالبته! کلِ این کتاب در پشت‌پرده یه بیانیه‌ی گنده‌ی لاندا ِه.

    پس تایپِ ‏‎s‎‏ چه کاربردی داره؟ خب، با اینکه میشه مؤدبانه از برنامه‌نویس‌ها خواهش کرد که یه مرجعِ تغییرپذیر (به عنوانِ جوابِ آخر) رو به یه ساختار داده ِ پایا و تغییرناپذیر فریز کنن... نمیشه روش حساب باز کرد. ‏‎ST‎‏ چنین چیزی رو در لحظه‌ی کامپایل اجبار می‌کنه، طوری که ‏‎s‎‏ هیچ وقت با هیچ چیز خارج از ‏‎ST Monad‎‏ قاطی نمیشه. به کَلکِ این کار میگن سورِ وجودی،* اما قرار نیست فعلاً برای شما معنایی داشته باشه. فقط کمک می‌کنه ارجاع‌های تغییرپذیر رو اشتباهاً به کُدِ خارج از ‏‎ST‎‏ نشت ندین، که می‌تونه منجر به کُدی بشه که بسته به حالتِ بیت‌های حافظه کارهای غیرقابل پیش‌بینی انجام بده.

    *

    تایپ‌های با وجودیتِ سوری؛ در Wikibook ِ هسکل

    سعی کنین زیاد از ‏‎ST‎‏ ورود و خروج نکنین. ممکنه در شرایطی، این فریز و آب کردن ِ متعدد، انقدر هزینه رو بیشتر کنه که استفاده از ‏‎IO‎‏ به صرفه‌تر بشه. دسته‌ای کردنِ موتاسیون، برای عملکرد و خواناییِ کد خیلی خوبه.

    تمرین‌ها: بردار

    یه بنچمارک برای پروفایلینگِ مقدار حافظه‌ای که بردارهای جعبه‌شده و جعبه‌نشده (هردو محتویِ داده‌ها ِ یکسان) مصرف می‌کنن، setup کنین. این بنچمارک رو می‌تونین با یه بنچمارکِ دیگه هم ترکیب کنین (کاری که چند ثانیه‌ای طول بکشه). عمداً صورتِ تمرین رو مبهم گذاشتیم چون بالاخره باید خوندنِ مستندات و کدهای منبع رو یاد بگیرین.