۲۷ - ۲سنجش با Criterion
اینکه آدم بخواد بدونه کُدِش چقدر سریع کار میکنه، چیزِ خیلی رایجیه. اگه نتونین درست کُدِتون رو بِسَنجین، نمیتونین بدونین ۶ میکروثانیه استفاده کردین یا فقط ۵ تا. اون موقع فقط باید از خودتون بپرسین: "حس میکنم خوششانسَم؟"*
م. شوخی رو گرفتین؟؟
اگه ترجیح میدین عملکرد ِ کُدتون رو به حدس و گمان نسپرین، بهترین راهش اینه که چندبار عملکردِش رو اندازه بگیرین تا به یه بازهی اطمینان برسین. خوشبختانه همهی زحمتهای این کار رو برایان اُسالیوان در کتابخونه ِ محشرِ criterion کشیده.
criterion آموزش خیلی خوبی هم داره، با این حال ما هم روی یه مثال کار میکنیم تا بتونین با این فصل پیش برین. تو این برنامه یه نسخه از (!!) مینویسیم که Maybe برمیگردونه تا دیگه نیازی به تهی نباشه. موقعِ کامپایل ِ کد برای سنجش، یادتون باشه از پرچمهای -O یا -O2 برای GHC استفاده کنین. هم میشه بصورت دستی در زمانِ اجرای GHC تعیینشون کرد:
-- stack با
$ stack ghc -- -O2 bench.hs
-- stack بدون
$ ghc -O2 bench.hsهم میشه از طریق تنظیمات Cabal (ghc-options) تعیینشون کرد.
خوب، اول ماژول رو آماده کنیم:
module Main where
import Criterion.Main
infixl 9 !?
_ !? n | n < 0 = Nothing
[] !? _ = Nothing
(x:_) !? 0 = Just x
(_:xs) !? n = xs !? (n-1)
myList :: [Int]
myList = [1..9999]
main :: IO ()
main = defaultMain
[ bench "index list 9999" $
whnf (myList !!) 9998
, bench "index list maybe index 9999" $
whnf (myList !?) 9998
]نسخهای که خودمون از (!!) تعریف کردیم چیزِ جدیدی نداره. گفتیم که یه اوپراتور میانوند با شرکتپذیری از چپ (infixl) با تقدم ِ ۹ باشه. از فصلِ ۲ به بعد زیاد از این دوتا صحبت نکردیم. اوپراتور ِ (!!) ِ معمولی که در base تعریف شده هم همین تقدم و شرکتپذیری رو داره.
اگه از یه ماژول ِ Main بنچمارک میگیرین، باید ماژول ِ Criterion.Main رو از criterion وارد کنین. معمولاً یه پاراگراف ِ بنچمارک توی فایلِ .cabal نوشته میشه که مثل یه اجراشدنی رفتار میکنه. اما میشه با Stack بنچمارکِ "یکبار مصرف" هم داشت:
$ stack build criterion
$ stack ghc -- -O2 benchIndex.hs
$ ./benchIndexتو مثالی که بالاتر نوشتیم، main از یکی از تابعهای criterion به اسمِ whnf استفاده میکنه. همونطور که احتمالاً حدس میزنین، تابعهای whnf و nf (اون هم از criterion) به حالت معمولی با سر ضعیف و حالت معمولی اشاره دارن. قبلاً گفتیم که حالت معمولی با سر ضعیف تا اولین دادهساز حساب میکنه. یعنی اگه بیرونیترین دادهسازِتون یه Maybe باشه، فقط اونقدری حساب میکنه که بفهمه Just ِه یا Nothing – اگه Just a دارین، هزینهی محاسبهی اون مقدار a رو حساب نمیکنه.
اگه از nf استفاده میکردین، یعنی میخواستین علاوه بر هزینهی محاسبهی اولین دادهساز، هزینهی محاسبهی کاملِ a هم حساب میشد. برای تشخیص بین اینکه whnf مناسبتره یا nf، باید ببینین چه چیزی رو میخواین بنچمارک کنین، و اینکه رسیدن به دادهساز ِ اول همهی کاری که میخواین اندازه بگیرین رو انجام میده یا نه. تفاوتِ بین این دوتا و نحوهی تصمیمگیری بینشون رو بعداً بیشتر توضیح میدیم.
تو این مثال، دوتا چیز رو میخوایم مقایسه کنیم: زمانِ محاسبه تا حالت معمولی با سر ضعیف، بعد از اعمال ِ اوپراتورِ اندیسگیری به اون لیستِ طولانی؛ یکی با استفاده از اوپراتور اصلی و یکی هم با اون نسخهی امنی که خودمون تعریف کردیم. دلیلی که فقط تا حالت معمولی با سر ضعیف لازم داریم، اینه که هم (!!) و (!?) هر دو وقتی یه دادهساز برمیگردونن که همهی کار رو کردن. از سه حالتِ اول معلومه:
_ !? n | n < 0 = Nothing
[] !? _ = Nothing
(x:_) !? 0 = Just xتابع فقط وقتی به این سه حالت میرسه که همون قدری که قراره لیست رو طی کنه، طی کرده باشه. حالت بازگشتی ِ زیر دادهسازی برنمیگردونه. در عوض انقدر خودش رو صدا میزنه تا به یکی از سه حالتِ بالا برسه. محاسبه تا WHNF در یه موردِ بازگشتی مثل این متوقف نمیشه (نمیتونه):
(_:xs) !? s = x !? (n-1)
-- تابع خودش رو صدا زده،
-- نیست. WHNF هنوزوقتی کد بالا تا حالت معمولی حساب بشه، انقدر ادامه میده تا به اندیس ِ مورد نظر برسه؛ یا میرسه به المان، یا میرسه به آخرِ لیست. یه مثال ببینیم:
[1, 2, 3] !? 2
-- منطبق با حالت آخر
(_: [2, 3]) !? 2
= [2, 3] !? (2-1)
-- دادهساز نیست، ادامه بده
[2, 3] !? 1
-- منطبق با حالت آخر
(_: [3]) !? 1
= [3] !? (1-1)
-- دادهساز نیست، ادامه بده
[3] !? 0
-- Just منطبق با حالت
(x:[]) !? 0 = Just x
-- متوقف میشیم. Just سَرِ اینجا برحسبِ اتفاق میدونستیم که x مقدارِش ۳ بود، اما اگه در لحظهی ساختِ لیست به صورتِ فرصتطلبانه محاسبه نشده بود، ثانک میشد.
حالا تایپِ این تابعها رو ببینیم:
defaultMain :: [Benchmark] -> IO ()
whnf :: (a -> b) -> a -> Benchmarkable
nf :: Control.DeepSeq.NFData b =>
(a -> b) -> a -> Benchmarkableدلیل اینکه اون دوتا تابع برای آرگومانِ اولشون یه تابع میخوان اینه که جوابشون به اشتراک گذاشته نشه، چیزی که فصلِ قبل توضیح دادیم. میخوایم برای هر نمونهی بنچمارک، کلِ کار رو دوباره انجام بده، و این طراحی جلوی اشتراکگذاری رو میگیره. دقت کنین که اگه میخواین از نوعداده ِ خودتون با nf استفاده کنین، باید یه نمونه ِ NFData براش تعریف کنین. مثالهایی از این کار رو میتونین از کتابخونه ِ deepseq در Hackage پیدا کنین.
هدف ما تو این مثال، اینه که کاری کنیم عملکرد ِ (!?) و (!!) یکسان بشن. تعریفی که اینجا برای (!?) نوشتیم براساسِ تعریف (!!) در گزارش هسکل ِه. توی base این شکلیه:
-- و base در Data.List برین به مستنداتِ
-- کلیک کنین (!!) برای source روی لینک
#ifdef USE_REPORT_PRELUDE
xs !! n | n < 0 =
error "Prelude.!!: negative index"
[] !! _ =
error "Prelude.!!: index too large"
(x:_) !! 0 = x
(_:xs) !! n = xs !! (n-1)
#elseبا این حال بعد از اینکه بنچمارکها رو اجرا کنین، میبینین که نسخهای که ما براساس کدِ بالا نوشتیم، خیلی سریع نیست.* خب، درسته! اکثر وقتهایی که هم یه نسخهی Report و هم یه نسخهی غیرِ Report از یه تابع در base هست، به این خاطره که یه راه برای بهینه و سریعتر کردنش پیدا شده بوده. اگه پایینترِ #else رو نگاه کنیم، نسخهای که جایگزین شده رو پیدا میکنیم:
-- ، وbottom یه negIndex
-- هست const bottom یه tooLarge
{-# INLINABLE (!!) #-}
xs !! n
| n < 0 = negIndex
| otherwise =
foldr
(\x r k -> case k of
0 -> x
_ -> r (k-1))
tooLarge xs nاگه نتایج عجیبغریبی از بنچمارک گرفتین، ممکنه کلکِ پاک کردنِ buildِتون، کمک کنه. اگه با Stack کار میکنین، دستورِ stack clean، و اگه با Cabal کار میکنین، دستورِ cabal clean رو اجرا کنین. گاهی اوقات اتفاقات غیرقابل توضیح پیش میان؛ ولی این کار زیاد لازم نمیشه.
نسخهی غیرِ Report با foldr نوشته شده، و foldr در اکثر موارد از بهینهسازیهای متنوع و چندتا قانونِ بازنویسی بهرهمنده (با عرض پوزش، این قوانین رو توضیح نمیدیم). این نسخه یه پراگما هم داره که به GHC میگه در صورتی که تخمینزنندهی هزینه صلاح دونست، اجازه داره جایی که تابع استفاده شده، کُدِش رو دَرخط کنه. پس همین تغییرات رو به نسخهی خودمون اضافه کنیم تا از همین بهینهسازیها استفاده کنه:
infixl 9 !?
{-# INLINABLE (!!) #-}
xs !? n
| n < 0 = Nothing
| otherwise =
foldr
(\x r k ->
case k of
0 -> Just x
_ -> r (k-1))
(const Nothing) xs nاگه این رو اجرا کنین... میبینین فرقِ زیادی نکرده. پس چی کار میشه کرد تا عملکردِش بهتر شه؟
خوب، تایپ سیگنچری در کار نیست (مگه خودتون نوشته باشین). با تعیینِ تایپِ اون آرگومانِ عددی به Int، عملکردِش مثلِ اوپراتورِ اصلی میشه:
infixl 9 !?
{-# INLINABLE (!!) #-}
(!?) :: [a] -> Int -> Maybe a
xs !? n
| n < 0 = Nothing
| otherwise =
foldr
(\x r k ->
case k of
0 -> Just x
_ -> r (k-1))
(const Nothing) xs nتابعِ توی ماژولِتون رو به این تغییر بدین و دوباره بنچمارک بگیرین تا مطمئن بشین.
مشکل اینجا بود که Num a => a به تایپِ Integer تعیین میشد (از طریقِ پیشفرضیسازی تایپ)، و کلاً Integer بازدهیِ کمتری نسبت به Int داره. نسخهای که با Int نوشته شده، تبدیل به یه حلقه ِ ابتداییتر و سریعتر میشه. خودتون از دو راه میتونین ببینین: یکی اینکه Int رو با Integer عوض کنین و کدِ رو دوباره اجرا کنین، یا GHC Core ِ خروجیِ هر نسخه رو با هم مقایسه کنین.
توضیح بیشتر برای whnf و nf
برگردیم به اینکه چطور بین whnf و nf تصمیم بگیریم. whnf برای زمانی خوبه که رسیدن به اولین دادهساز، نشونهی خوبی از انجامِ کاری که میخواین اندازه بگیرین باشه. یه مثال ساده بزنیم، برنامهای برای پیدا کردنِ یه جور داده از یه پایگاه داده در نظر بگیرین، بر فرض برنامهای که دنبال اسمِ یه شخص میگرده و میبینه که آیا آدرسی برای اون آدم ثبت شده یا نه. اگه دادهای پیدا کنه، ممکنه اون اطلاعات رو توی یه فایل چاپ کنه.
به احتمال زیاد، اون بخشی از برنامه که عملکردِش مَدِ نظره، تابعِ جستجو ایه که داده رو پیدا و وجودیتش رو تعیین میکنه؛ نه سرعتِ کامپیوتر در چاپِ یه لیست آدرس توی فایل. در چنین حالتی، چیزی که موردِ نظره در سطحِ حالت معمولی با سر ضعیف ِه، و whnf زمان لازم برای پیداکردنِ داده و تعیین اینکه Nothing ِه یا Just a رو دقیقتر میگه.
اما اگه علاوه بر زمان لازم برای پیداکردنِ داده، زمان لازم برای چاپِ نتایج هم مهم باشه، اون موقع باید تا حالت معمولی محاسبه بشه. گاهی اوقات چنین اندازهگیریای به درد میخوره. به زودی چندتا مثال میبینیم.
فعلاً دوتا اوپراتورِ اندیسگیری که داریم رو در نظر میگیریم: یکی (!!) که در base تعریف شده، و یکی اونی که خودمون نوشتیم و بجای تهی از Maybe استفاده میکنه.
در اولی، تایپِ نتیجهی نهایی میشه a. تابع انقدر خودش رو صدا میزنه تا یا تهی برگردونه یا مقدارِ اون اندیس رو. در هر صورت، کلِ کاری که میخواین اندازه بگیرین رو انجام میده: طیکردنِ لیست. محاسبه تا WHNF یعنی سرِ مقدارِ a متوقف میشه.
در دومی که با Maybe کار میکنه، محاسبه تا WHNF یعنی یا سرِ Just یا سرِ Nothing متوقف میشه. با whnf محتویاتِ دادهساز ِ Just محاسبه نمیشه، اما با nf محاسبه میشن. چون اینجا میخوایم بینیم چقدر طول میکشه تا این کد به مقدار واقع در اندیس ِ ورودیِ لیست برسه، هر دوی اونها برای بنچمارک مناسباند.
مثالمون رو با چندتا تغییر ببینیم:
module Main where
import Criterion.Main
import Debug.Trace
myList :: [Int]
myList = trace "myList was evaluated"
([1..9999] ++ [undefined])
-- رو بنویسین (!?) اینجا نسخهی خودتون برای
main :: IO ()
main = defaultMain
[ bench "index list 9999" $
whnf (myList !!) 9998
, bench "index list maybe index 9999" $
nf (myList !?) 9999
]دقت کنین چی کار کردیم. در اندیس ِ ۹۹۹۹ یه undefined اضافه کردیم و با اوپراتورِ (!!) به اندیس ِ قبلیش میرسیم، چون هیچ ساختارِ دادهای (مثلِ Nothing یا Just) اطرافش نداره که محاسبه رو نگه داره. هم whnf و هم nf الزاماً اون مقدارِ تهی رو اجبار میکنن.
برای بنچمارکِ (!?)، whnf رو به nf تغییر دادیم. حالا در اولین اجرای بنچمارک، undefined در اندیس ِ آخر رو محاسبه میکنه و شکست میخوره:
benchmarking index list maybe index 9999
criterion1: Prelude.undefinedیه مقدارِ تابعی که بجای یه ساختارِ داده تهی برگردونه هم، میتونه نقشِ نقطهی توقف برای WHNF رو بازی کنه. اینها رو ببینین:
Prelude> (Just undefined) `seq` 1
1
Prelude> (\_ -> undefined) `seq` 1
1
Prelude> ((\_ -> Just undefined) 0) `seq` 1
1
Prelude> ((\_ -> undefind) 0) `seq` 1
*** Exception: Prelude.undefinedبیشتر مواقع whnf جوابِ کار رو میده.
موردی که nf به کار میاد
حالا یه مثال ببینیم که whnf برای بنچمارکینگ کافی نیست. یه چیزی که بر خلافِ (!!)، بازگشتیِ حفاظتشده داره:
module Main where
import Criterion.Main
myList :: [Int]
myList = [1..9999]
main :: IO ()
main = defaultMain
[ bench "map list 9999" $
whnf (map (+1)) myList
]این یه مثال از بازگشتیِ حفاظتشده هست، چون یه دادهساز بین هر مرحلهی خوداتکایی قرار گرفته. وقتی با map سروکار داریم، اون ساختارِ داده میشه سلولِ cons. با بازگشتیِ حفاظتشده میشه مراحلِ خوداتکایی رو دونهبهدونه تا حالتِ معمولی با سر ضعیف مصرف کرد.
با foldr، هم میشه بازگشتی محافظتشده و هم میشه بازگشتیِ محافظتنشده تعریف کرد، که تماماً بستگی به کاری که تابع فولدینگ انجام میده داره تا قابلیتهای خودِ foldr. حالا نتیجهی بنچمارک چی میشه؟
Linking bin/bench ...
time
8.844 ns (8.670 ns .. 9.033 ns)
0.998 R² (0.997 R² .. 1.000 R²)
mean
8.647 ns (8.567 ns .. 8.751 ns)
std dev
293.3 ps (214.7 ps .. 412.9 ps)
variance introduced by outliers:
57% (severly inflated)خوب یه کم مشکوکه. واقعاً پیمایش ِ ۱۰۰۰۰ المانِ یه لیستِ پیوندی توی هسکل فقط ۸٫۸ نانوثانیه طول میکشه؟ قبلاً یه مثال از اینکه تقریباً چقدر باید طول بکشه دیدیم. اینجا بنچمارکمون زیادی تنبل ِه. مشکل اینجاست که map از بازگشتیِ محافتشده استفاده میکنه، و سلولهای cons ِ لیست، بینِ هر بازگشت ِ map قرار گرفتن. شاید از فصلهای لیست و فولد یادتون باشه. پس نهایتاً همینقدر بیشتر حساب نمیکنه:
(_ : _)پس نه یکی به مقدار اضافه کرده، نه مابقیِ لیست رو پیش رفته. همینطور سرِ سلول ِ اول نشسته. میشه undefined رو مرحله به مرحله با چیزهای دیگه جایگزین کرد و بنچمارک گرفت تا ببینیم whnf چی رو حساب میکنه:
-- رو اعمال میکنه؟ (+1)
myList = (undefined : [2..9999])
-- اصلاً تو لیست جلوتر میره؟
myList :: [Int]
myList = (undefined : undefined)
-- این دیگه باید بترکه چون دنبال یه
-- میگرده تا وایسه. (->) دادهساز اول یا
myList :: [Int]
myList = undefinedاشکالی نداره، درستش میکنیم!
-- این تیکه رو
whnf (map (+1)) myList
-- به این تغییر بدین:
nf (map (+1)) myListبعد نتیجه این میشه:
time
122.5 μs (121.7 μs .. 123.9 μs)
0.999 R² (0.998 R² .. 1.000 R²)
mean
123.0 μs (122.0 μs .. 125.6 μs)
std dev
5.404 μs (2.806 μs .. 9.323 μs)با درنظر داشتنِ اینکه تماماً یه لیست جدید ساختیم، این خیلی واقعیتره. به همین خاطر از اوپراتور اندیسگذاری کُندتَر شده – فقط یه مقدار نمیده بیرون، یه لیست جدید هم درست میکنه.
بطور کلی، برای تصمیمگیری بین whnf و nf، از خودتون بپرسین: "با رسیدن به دادهساز ِ اول، همه یا بیشترِ کاری که مهمه انجام میشه؟" فقط مراقب باشین زیادی از nf استفاده نکنین. اگه تابعی دارین که برای درستکردنِ یه ساختارِ داده ِ پیچیده همهی کار رو انجام میده، nf باعث میشه کُدتون زیادی کند به نظر برسه و بفرستتون دنبالِ نخود سیاه.