Haskell มีการเพิ่มประสิทธิภาพหางซ้ำหรือไม่?


90

วันนี้ฉันค้นพบคำสั่ง "time" ใน unix และคิดว่าจะใช้เพื่อตรวจสอบความแตกต่างของเวลาทำงานระหว่างฟังก์ชันการเรียกซ้ำแบบหางซ้ำและฟังก์ชันเรียกซ้ำแบบปกติใน Haskell

ฉันเขียนฟังก์ชันต่อไปนี้:

--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
    fac' 1 y = y
    fac' x y = fac' (x-1) (x*y) 

--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)

สิ่งเหล่านี้ถูกต้องโดยคำนึงว่ามีไว้สำหรับใช้กับโครงการนี้เท่านั้นดังนั้นฉันจึงไม่ต้องกังวลที่จะตรวจหาเลขศูนย์หรือจำนวนลบ

อย่างไรก็ตามเมื่อเขียนวิธีการหลักสำหรับแต่ละวิธีรวบรวมและเรียกใช้ด้วยคำสั่ง "เวลา" ทั้งสองมีเวลาทำงานที่คล้ายคลึงกันกับฟังก์ชันการเรียกซ้ำตามปกติ สิ่งนี้ตรงกันข้ามกับสิ่งที่ฉันเคยได้ยินเกี่ยวกับการเพิ่มประสิทธิภาพหางซ้ำในเสียงกระเพื่อม มันมีเหตุผลอะไร


8
ฉันเชื่อว่า TCO เป็นการเพิ่มประสิทธิภาพเพื่อบันทึก call stack บางส่วน แต่ไม่ได้หมายความว่าคุณจะประหยัดเวลา CPU ได้บ้าง แก้ไขฉันถ้าผิด
เจอโรม

3
ยังไม่ได้ทดสอบด้วยเสียงกระเพื่อม แต่บทช่วยสอนที่ฉันอ่านบอกเป็นนัยว่าการตั้งค่าสแต็กนั้นทำให้ต้นทุนของตัวประมวลผลเพิ่มขึ้นในตัวเองในขณะที่โซลูชันหางซ้ำที่คอมไพล์ถึงวนซ้ำไม่ได้ใช้พลังงาน (เวลา) ใด ๆ ในการทำเช่นนี้ มีประสิทธิภาพมากขึ้น
haskell rascal

1
@Jerome ขึ้นอยู่กับหลายสิ่งหลายอย่าง แต่โดยทั่วไปแล้วแคชก็เข้ามามีบทบาทดังนั้น TCO มักจะสร้างโปรแกรมที่เร็วกว่าเช่นกัน ..
Kristopher Micinski

มันมีเหตุผลอะไร ในคำ: ความเกียจคร้าน
Dan Burton

ที่น่าสนใจfacคือคุณคำนวณ ghc product [n,n-1..1]โดยใช้ฟังก์ชันเสริมได้มากหรือน้อยprodแต่แน่นอนว่าproduct [1..n]จะง่ายกว่า ฉันสามารถสันนิษฐานได้ว่าพวกเขาไม่ได้เข้มงวดในอาร์กิวเมนต์ที่สองเนื่องจากว่านี่เป็นสิ่งที่ ghc มั่นใจมากว่ามันสามารถรวบรวมลงในตัวสะสมอย่างง่าย
AndrewC

คำตอบ:


171

Haskell ใช้การประเมินแบบขี้เกียจเพื่อใช้การเรียกซ้ำดังนั้นให้ถือว่าทุกอย่างเป็นสัญญาว่าจะให้ค่าเมื่อจำเป็น (เรียกว่า thunk) Thunks จะลดลงเท่าที่จำเป็นเท่านั้นเพื่อดำเนินการต่อไป สิ่งนี้คล้ายกับวิธีที่คุณทำให้นิพจน์ง่ายขึ้นในทางคณิตศาสตร์ดังนั้นจึงเป็นประโยชน์ที่จะคิดอย่างนั้น ความจริงที่ว่าโค้ดของคุณไม่ได้ระบุลำดับการประเมินทำให้คอมไพเลอร์สามารถทำการเพิ่มประสิทธิภาพที่ชาญฉลาดได้มากมายกว่าการกำจัด tail-call ที่คุณคุ้นเคย รวบรวม-O2หากคุณต้องการเพิ่มประสิทธิภาพ!

มาดูกันว่าเราประเมินfacSlow 5เป็นกรณีศึกษาอย่างไร:

facSlow 5
5 * facSlow 4            -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3)       -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2))  -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120

เช่นเดียวกับที่คุณกังวลเรามีการสร้างตัวเลขก่อนที่การคำนวณจะเกิดขึ้น แต่ไม่เหมือนกับที่คุณกังวลไม่มีfacSlowการเรียกใช้ฟังก์ชันซ้อนกันเพื่อรอการสิ้นสุด - การลดแต่ละครั้งจะถูกนำไปใช้และหายไปโดยทิ้งกรอบสแต็กไว้ ปลุก (นั่นเป็นเพราะ(*)เข้มงวดและทำให้เกิดการประเมินอาร์กิวเมนต์ที่สอง)

ฟังก์ชันเรียกซ้ำของ Haskell ไม่ได้รับการประเมินในลักษณะที่เรียกซ้ำมาก! สายซ้อนเพียงสายเดียวคือการคูณด้วยตัวมันเอง หาก (*)ถูกมองว่าเป็นตัวสร้างข้อมูลที่เข้มงวดนี่คือสิ่งที่เรียกว่าการเรียกซ้ำที่มีการป้องกัน (แม้ว่าโดยปกติจะอ้างถึงสิ่งนั้นด้วยตัวสร้างข้อมูลที่ไม่จำกัด ซึ่งสิ่งที่เหลืออยู่คือตัวสร้างข้อมูล - เมื่อถูกบังคับโดยการเข้าถึงเพิ่มเติม)

ตอนนี้เรามาดูหางซ้ำfac 5:

fac 5
fac' 5 1
fac' 4 {5*1}       -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}}    -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}        -- the thunk "{...}" 
(2*{3*{4*{5*1}}})        -- is retraced 
(2*(3*{4*{5*1}}))        -- to create
(2*(3*(4*{5*1})))        -- the computation
(2*(3*(4*(5*1))))        -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120

คุณจะเห็นได้ว่าการเรียกหางซ้ำด้วยตัวมันเองไม่ได้ช่วยคุณประหยัดเวลาหรือพื้นที่ โดยรวมไม่เพียง แต่ใช้เวลามากกว่าขั้นตอนโดยรวมfacSlow 5เท่านั้น แต่ยังสร้าง thunk ที่ซ้อนกัน (แสดงที่นี่{...}) - ต้องการพื้นที่พิเศษสำหรับมัน - ซึ่งอธิบายการคำนวณในอนาคตการคูณที่ซ้อนกันที่จะดำเนินการ

thunk นี้จะหลุดแล้วโดยภายในมันไปที่ด้านล่างสูตรการคำนวณในกอง นอกจากนี้ยังมีอันตรายที่จะทำให้สแตกล้นด้วยการคำนวณที่ยาวมากสำหรับทั้งสองเวอร์ชัน

หากเราต้องการเพิ่มประสิทธิภาพด้วยมือสิ่งที่เราต้องทำคือทำให้เข้มงวด คุณสามารถใช้ตัวดำเนินการแอปพลิเคชันที่เข้มงวด$!เพื่อกำหนด

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

สิ่งนี้บังคับfacS'ให้เข้มงวดในการโต้แย้งครั้งที่สอง (มันเข้มงวดอยู่แล้วในข้อโต้แย้งแรกเพราะต้องได้รับการประเมินเพื่อตัดสินใจว่าfacS'จะใช้คำจำกัดความใด)

บางครั้งความเข้มงวดสามารถช่วยได้อย่างมหาศาลบางครั้งก็เป็นความผิดพลาดครั้งใหญ่เพราะความขี้เกียจมีประสิทธิภาพมากกว่า นี่เป็นความคิดที่ดี:

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

ซึ่งเป็นสิ่งที่คุณต้องการบรรลุฉันคิดว่า

สรุป

  • หากคุณต้องการเพิ่มประสิทธิภาพโค้ดของคุณขั้นตอนที่หนึ่งคือการคอมไพล์ด้วย -O2
  • การเรียกหางซ้ำจะดีก็ต่อเมื่อไม่มีการสะสมของลำตัวและการเพิ่มความเข้มงวดมักจะช่วยป้องกันได้ตามความเหมาะสม สิ่งนี้จะเกิดขึ้นเมื่อคุณสร้างผลลัพธ์ที่จำเป็นในภายหลังพร้อมกัน
  • บางครั้งการเรียกซ้ำหางเป็นแผนการที่ไม่ดีและการเรียกซ้ำที่มีการป้องกันนั้นเหมาะสมกว่ากล่าวคือเมื่อผลลัพธ์ที่คุณสร้างขึ้นจะเป็นที่ต้องการทีละนิด ดูคำถามเกี่ยวกับfoldrและfoldlตัวอย่างนี้แล้วทดสอบกันเอง

ลองสองข้อนี้:

length $ foldl1 (++) $ replicate 1000 
    "The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000 
    "The number of reductions performed is more important than tail recursion!!!"

foldl1เป็นหางซ้ำในขณะที่foldr1ดำเนินการเรียกซ้ำที่มีการป้องกันเพื่อให้รายการแรกถูกนำเสนอทันทีสำหรับการประมวลผล / การเข้าถึงเพิ่มเติม ("วงเล็บ" อันแรกไปทางซ้ายพร้อมกันโดย(...((s+s)+s)+...)+sบังคับให้รายการอินพุตจนสุดและสร้างการคำนวณครั้งใหญ่ในอนาคตเร็วกว่าที่ต้องการผลลัพธ์ทั้งหมดวงเล็บที่สองจะค่อยๆไปทางขวาs+(s+(...+(s+s)...))โดยใช้อินพุต ทีละรายการดังนั้นสิ่งทั้งหมดจึงสามารถทำงานในพื้นที่คงที่ด้วยการปรับให้เหมาะสม)

คุณอาจต้องปรับจำนวนศูนย์ขึ้นอยู่กับฮาร์ดแวร์ที่คุณใช้


1
@ WillNess เยี่ยมมากขอบคุณ ไม่จำเป็นต้องถอนกลับ ฉันคิดว่ามันเป็นคำตอบที่ดีกว่าสำหรับลูกหลานในตอนนี้
AndrewC

4
นี้เป็นที่ดี แต่ผมอาจแนะนำพยักหน้าไปสู่การวิเคราะห์เข้มงวด ? ฉันคิดว่าเกือบจะทำงานได้ดีสำหรับแฟกทอเรียลหางซ้ำใน GHC เวอร์ชันล่าสุดที่สมเหตุสมผล
dfeuer

16

ควรจะกล่าวถึง facฟังก์ชันนี้ไม่ใช่ตัวเลือกที่ดีสำหรับการเรียกซ้ำที่มีการป้องกัน การเรียกซ้ำหางเป็นวิธีที่จะไปที่นี่ เนื่องจากความเกียจคร้านคุณจะไม่ได้รับผลกระทบของ TCO ในfac'ฟังก์ชันของคุณเนื่องจากอาร์กิวเมนต์ตัวสะสมยังคงสร้าง thunks จำนวนมากซึ่งเมื่อประเมินแล้วจะต้องใช้กองซ้อนจำนวนมาก เพื่อป้องกันสิ่งนี้และได้รับผลกระทบที่ต้องการของ TCO คุณต้องทำให้อาร์กิวเมนต์สะสมเหล่านี้เข้มงวด

{-# LANGUAGE BangPatterns #-}

fac :: (Integral a) => a -> a
fac x = fac' x 1 where
  fac' 1  y = y
  fac' x !y = fac' (x-1) (x*y)

หากคุณรวบรวมโดยใช้-O2(หรือ-Oเฉยๆ) GHC อาจดำเนินการได้ด้วยตัวเองในขั้นตอนการวิเคราะห์ความเข้มงวด


4
ฉันคิดว่ามันชัดเจน$!กว่าด้วยBangPatternsแต่นี่เป็นคำตอบที่ดี โดยเฉพาะการกล่าวถึงการวิเคราะห์ความเข้มงวด.
singpolyma

7

คุณควรตรวจสอบบทความวิกิพีเดียในการเรียกซ้ำหางใน Haskell โดยเฉพาะอย่างยิ่งเนื่องจากการประเมินนิพจน์ประเภทของการเรียกซ้ำที่คุณต้องการจะได้รับการปกป้องเรียกซ้ำ หากคุณหารายละเอียดเกี่ยวกับสิ่งที่เกิดขึ้นภายใต้ประทุน (ในเครื่องนามธรรมสำหรับ Haskell) คุณจะได้สิ่งเดียวกันกับการเรียกซ้ำหางในภาษาที่เข้มงวด นอกจากนี้คุณยังมีไวยากรณ์ที่เหมือนกันสำหรับฟังก์ชัน lazy (การเรียกซ้ำหางจะผูกคุณกับการประเมินที่เข้มงวดในขณะที่การเรียกซ้ำที่มีการป้องกันจะทำงานได้อย่างเป็นธรรมชาติมากกว่า)

(และในการเรียนรู้ Haskell หน้าวิกิที่เหลือเหล่านั้นก็ยอดเยี่ยมเช่นกัน!)


0

ถ้าฉันจำได้อย่างถูกต้อง GHC จะปรับฟังก์ชั่นการเรียกซ้ำแบบธรรมดาโดยอัตโนมัติให้เป็นฟังก์ชันที่ปรับให้เหมาะสมแบบ tail-recursive

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.