Giảm thời gian tạm dừng thu gom rác trong chương trình Haskell


130

Chúng tôi đang phát triển một chương trình nhận và chuyển tiếp "tin nhắn", trong khi vẫn giữ lịch sử tạm thời của những tin nhắn đó, để nó có thể cho bạn biết lịch sử tin nhắn nếu được yêu cầu. Tin nhắn được xác định bằng số, thường có kích thước khoảng 1 kilobyte và chúng tôi cần giữ hàng trăm ngàn tin nhắn này.

Chúng tôi muốn tối ưu hóa chương trình này cho độ trễ: thời gian giữa gửi và nhận tin nhắn phải dưới 10 mili giây.

Chương trình được viết bằng Haskell và được biên dịch bằng GHC. Tuy nhiên, chúng tôi đã phát hiện ra rằng việc tạm dừng thu gom rác quá dài đối với các yêu cầu về độ trễ của chúng tôi: hơn 100 mili giây trong chương trình thế giới thực của chúng tôi.

Chương trình sau đây là phiên bản đơn giản hóa của ứng dụng của chúng tôi. Nó sử dụng một Data.Map.Strictđể lưu trữ tin nhắn. Tin nhắn được ByteStringxác định bởi một Int. 1.000.000 tin nhắn được chèn theo thứ tự số tăng dần và các tin nhắn cũ nhất sẽ liên tục bị xóa để giữ lịch sử ở mức tối đa 200.000 tin nhắn.

module Main (main) where

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import qualified Data.Map.Strict as Map

data Msg = Msg !Int !ByteString.ByteString

type Chan = Map.Map Int ByteString.ByteString

message :: Int -> Msg
message n = Msg n (ByteString.replicate 1024 (fromIntegral n))

pushMsg :: Chan -> Msg -> IO Chan
pushMsg chan (Msg msgId msgContent) =
  Exception.evaluate $
    let
      inserted = Map.insert msgId msgContent chan
    in
      if 200000 < Map.size inserted
      then Map.deleteMin inserted
      else inserted

main :: IO ()
main = Monad.foldM_ pushMsg Map.empty (map message [1..1000000])

Chúng tôi đã biên dịch và chạy chương trình này bằng cách sử dụng:

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.10.3
$ ghc -O2 -optc-O3 Main.hs
$ ./Main +RTS -s
   3,116,460,096 bytes allocated in the heap
     385,101,600 bytes copied during GC
     235,234,800 bytes maximum residency (14 sample(s))
     124,137,808 bytes maximum slop
             600 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0      6558 colls,     0 par    0.238s   0.280s     0.0000s    0.0012s
  Gen  1        14 colls,     0 par    0.179s   0.250s     0.0179s    0.0515s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.652s  (  0.745s elapsed)
  GC      time    0.417s  (  0.530s elapsed)
  EXIT    time    0.010s  (  0.052s elapsed)
  Total   time    1.079s  (  1.326s elapsed)

  %GC     time      38.6%  (40.0% elapsed)

  Alloc rate    4,780,213,353 bytes per MUT second

  Productivity  61.4% of total user, 49.9% of total elapsed

Số liệu quan trọng ở đây là "tạm dừng tối đa" 0,0515 giây, hoặc 51 mili giây. Chúng tôi muốn giảm điều này ít nhất là một thứ tự cường độ.

Thử nghiệm cho thấy độ dài của tạm dừng GC được xác định bởi số lượng tin nhắn trong lịch sử. Mối quan hệ gần như tuyến tính, hoặc có lẽ siêu tuyến tính. Bảng dưới đây cho thấy mối quan hệ này. ( Bạn có thể xem các bài kiểm tra điểm chuẩn của chúng tôi ở đâymột số biểu đồ ở đây .)

msgs history length  max GC pause (ms)
===================  =================
12500                                3
25000                                6
50000                               13
100000                              30
200000                              56
400000                             104
800000                             199
1600000                            487
3200000                           1957
6400000                           5378

Chúng tôi đã thử nghiệm với một số biến số khác để tìm hiểu xem liệu chúng có thể giảm độ trễ này hay không, không biến nào tạo ra sự khác biệt lớn. Trong số các biến không quan trọng này là: tối ưu hóa ( -O, -O2); Tùy chọn RTS GC ( -G, -H, -A, -c), số lượng lõi ( -N), cấu trúc dữ liệu khác nhau ( Data.Sequence), kích thước của bài viết, và số lượng được tạo ra rác ngắn ngủi. Yếu tố quyết định áp đảo là số lượng tin nhắn trong lịch sử.

Lý thuyết làm việc của chúng tôi là các tạm dừng là tuyến tính trong số lượng tin nhắn vì mỗi chu kỳ GC phải đi qua tất cả bộ nhớ có thể truy cập được và sao chép nó, đó là các hoạt động tuyến tính rõ ràng.

Câu hỏi:

  • Liệu lý thuyết thời gian tuyến tính này có đúng không? Độ dài của tạm dừng GC có thể được thể hiện theo cách đơn giản này, hoặc thực tế phức tạp hơn?
  • Nếu tạm dừng GC là tuyến tính trong bộ nhớ làm việc, có cách nào để giảm các yếu tố liên tục không?
  • Có bất kỳ lựa chọn cho GC gia tăng, hoặc bất cứ điều gì giống như nó? Chúng tôi chỉ có thể xem các tài liệu nghiên cứu. Chúng tôi rất sẵn sàng giao dịch thông lượng cho độ trễ thấp hơn.
  • Có cách nào để "phân vùng" bộ nhớ cho các chu trình GC nhỏ hơn, ngoài việc chia thành nhiều quy trình không?

1
@Bakuriu: đúng, nhưng 10 ms có thể đạt được với hầu hết mọi hệ điều hành hiện đại mà không cần chỉnh sửa. Khi tôi chạy các chương trình C đơn giản, ngay cả trên Raspberry pi cũ của tôi, chúng dễ dàng đạt được độ trễ trong phạm vi 5 ms hoặc ít nhất là đáng tin cậy như 15 ms.
leftaroundabout

3
Bạn có tự tin rằng trường hợp thử nghiệm của bạn là hữu ích (ví dụ như bạn không sử dụng COntrol.Concurrent.Chan? Các đối tượng có thể thay đổi thay đổi phương trình)? Tôi sẽ đề nghị bắt đầu bằng cách đảm bảo bạn biết loại rác nào bạn tạo ra và tạo ra càng ít càng tốt (ví dụ: đảm bảo sự hợp nhất xảy ra, hãy thử -funbox-strict). Có thể thử sử dụng một lib phát trực tuyến (iostreams, ống dẫn, ống dẫn, phát trực tuyến) và gọi performGCtrực tiếp trong khoảng thời gian thường xuyên hơn.
jberryman

6
Nếu những gì bạn đang cố gắng thực hiện có thể được thực hiện trong không gian liên tục, thì hãy bắt đầu bằng cách cố gắng thực hiện điều đó (ví dụ: có thể là bộ đệm vòng từ MutableByteArray; GC sẽ không liên quan gì đến trường hợp đó)
jberryman

1
Đối với những người đề xuất các cấu trúc có thể thay đổi và chú ý tạo ra rác tối thiểu, lưu ý rằng đó là kích thước được giữ lại , chứ không phải lượng rác được thu thập có vẻ như chỉ ra thời gian tạm dừng. Buộc các bộ sưu tập thường xuyên hơn dẫn đến nhiều lần tạm dừng có cùng độ dài. Chỉnh sửa: Cấu trúc off-heap Mutable có thể thú vị, nhưng gần như không có nhiều niềm vui để làm việc trong nhiều trường hợp!
mike

6
Mô tả này chắc chắn gợi ý rằng thời gian của GC sẽ là tuyến tính theo kích thước của heap cho tất cả các thế hệ, các yếu tố quan trọng là kích thước của các đối tượng được giữ lại (để sao chép) và số lượng con trỏ hiện có cho chúng (để nhặt rác): ghc.haskell. org / trac / ghc / wiki / Bình luận / Rts / Storage / GC / Sao chép
mike

Câu trả lời:


96

Bạn thực sự đang làm khá tốt để có thời gian tạm dừng 51ms với hơn 200Mb dữ liệu trực tiếp. Hệ thống tôi làm việc có thời gian tạm dừng tối đa lớn hơn với một nửa lượng dữ liệu trực tiếp.

Giả định của bạn là chính xác, thời gian tạm dừng chính của GC tỷ lệ thuận với lượng dữ liệu trực tiếp và thật không may, không có cách nào khác với GHC như hiện tại. Chúng tôi đã thử nghiệm với GC gia tăng trong quá khứ, nhưng đó là một dự án nghiên cứu và không đạt đến mức trưởng thành cần thiết để xếp nó vào GHC được phát hành.

Một điều mà chúng tôi hy vọng sẽ giúp với điều này trong tương lai là các khu vực nhỏ gọn: https://phovenator.haskell.org/D1264 . Đó là một kiểu quản lý bộ nhớ thủ công trong đó bạn thu gọn cấu trúc trong heap và GC không phải vượt qua nó. Nó hoạt động tốt nhất cho dữ liệu tồn tại lâu, nhưng có lẽ nó sẽ đủ tốt để sử dụng cho các tin nhắn riêng lẻ trong cài đặt của bạn. Chúng tôi đang nhắm đến việc có nó trong GHC 8.2.0.

Nếu bạn đang ở trong một cài đặt phân tán và có một số loại cân bằng tải, có một số mẹo bạn có thể chơi để tránh thực hiện nhấn tạm dừng, về cơ bản, bạn chắc chắn rằng bộ cân bằng tải không gửi yêu cầu đến các máy sắp làm một GC chính, và tất nhiên đảm bảo rằng máy vẫn hoàn thành GC mặc dù nó không nhận được yêu cầu.


13
Xin chào Simon, cảm ơn bạn rất nhiều vì đã trả lời chi tiết của bạn! Đó là tin xấu, nhưng tốt để đóng cửa. Chúng tôi hiện đang hướng tới một triển khai có thể thay đổi là sự thay thế phù hợp duy nhất. Một số điều chúng tôi không hiểu: (1) Các thủ thuật liên quan đến sơ đồ cân bằng tải - chúng có liên quan đến thủ công performGCkhông? (2) Tại sao việc nén với hiệu năng -ckém hơn - chúng tôi cho rằng vì nó không tìm thấy nhiều thứ mà nó có thể để tại chỗ? (3) Có thêm chi tiết nào về máy compact không? Nghe có vẻ rất thú vị nhưng thật không may, nó hơi xa trong tương lai để chúng ta xem xét.
jameshfisher

2
@mljrg bạn có thể quan tâm đến well-typed.com/blog/2019/10/nonmoving-gc-merge
Alfredo Di Napoli

@AlfredoDiNapoli Cảm ơn bạn!
mljrg

9

Tôi đã thử đoạn mã của bạn với cách tiếp cận ringbuffer bằng cách sử dụng IOVectorlàm cấu trúc dữ liệu cơ bản. Trên hệ thống của tôi (GHC 7.10.3, cùng các tùy chọn biên dịch), điều này dẫn đến việc giảm thời gian tối đa (số liệu bạn đã đề cập trong OP của bạn) ~ 22%.

Lưu ý Tôi đã đưa ra hai giả định ở đây:

  1. Một cấu trúc dữ liệu có thể thay đổi là phù hợp với vấn đề (tôi đoán tin nhắn chuyển qua ngụ ý IO dù sao đi nữa)
  2. Tin nhắn của bạn liên tục

Với một Intsố tham số và số học bổ sung (như khi messageId được đặt lại về 0 hoặc minBound), sau đó sẽ đơn giản để xác định xem một tin nhắn nhất định có còn trong lịch sử hay không và lấy nó thành chỉ mục tương ứng trong trình ghi âm.

Đối với niềm vui thử nghiệm của bạn:

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import qualified Data.Map.Strict as Map

import qualified Data.Vector.Mutable as Vector

data Msg = Msg !Int !ByteString.ByteString

type Chan = Map.Map Int ByteString.ByteString

data Chan2 = Chan2
    { next          :: !Int
    , maxId         :: !Int
    , ringBuffer    :: !(Vector.IOVector ByteString.ByteString)
    }

chanSize :: Int
chanSize = 200000

message :: Int -> Msg
message n = Msg n (ByteString.replicate 1024 (fromIntegral n))


newChan2 :: IO Chan2
newChan2 = Chan2 0 0 <$> Vector.unsafeNew chanSize

pushMsg2 :: Chan2 -> Msg -> IO Chan2
pushMsg2 (Chan2 ix _ store) (Msg msgId msgContent) =
    let ix' = if ix == chanSize then 0 else ix + 1
    in Vector.unsafeWrite store ix' msgContent >> return (Chan2 ix' msgId store)

pushMsg :: Chan -> Msg -> IO Chan
pushMsg chan (Msg msgId msgContent) =
  Exception.evaluate $
    let
      inserted = Map.insert msgId msgContent chan
    in
      if chanSize < Map.size inserted
      then Map.deleteMin inserted
      else inserted

main, main1, main2 :: IO ()

main = main2

main1 = Monad.foldM_ pushMsg Map.empty (map message [1..1000000])

main2 = newChan2 >>= \c -> Monad.foldM_ pushMsg2 c (map message [1..1000000])

2
Chào! Câu trả lời tốt đẹp. Tôi nghi ngờ lý do điều này chỉ tăng tốc 22% là vì GC vẫn phải đi IOVectorvà các giá trị (bất biến, GC'd) ở mỗi chỉ số. Chúng tôi hiện đang điều tra các tùy chọn để triển khai lại bằng các cấu trúc có thể thay đổi. Nó có thể tương tự như hệ thống đệm vòng của bạn. Nhưng chúng tôi đang di chuyển nó hoàn toàn bên ngoài không gian bộ nhớ Haskell để thực hiện quản lý bộ nhớ thủ công của riêng chúng tôi.
jameshfisher

11
@jamesfisher: Tôi thực sự đã phải đối mặt với một vấn đề tương tự, nhưng đã quyết định tiếp tục quản lý mem ở phía Haskell. Giải pháp thực sự là một bộ đệm vòng, giữ một bản sao tạm thời của dữ liệu gốc trong một khối bộ nhớ duy nhất, liên tục, do đó dẫn đến một giá trị Haskell duy nhất. Có một cái nhìn về nó trong ý chính RingBuffer.hs này . Tôi đã kiểm tra mã này so với mã mẫu của bạn và đã tăng tốc khoảng 90% số liệu quan trọng. Hãy sử dụng mã một cách thuận tiện.
mgmeier

8

Tôi phải đồng ý với những người khác - nếu bạn gặp khó khăn trong thời gian thực, thì việc sử dụng ngôn ngữ GC là không lý tưởng.

Tuy nhiên, bạn có thể xem xét thử nghiệm với các cấu trúc dữ liệu có sẵn khác thay vì chỉ Data.Map.

Tôi viết lại nó bằng Data.Sequence và nhận được một số cải tiến đầy hứa hẹn:

msgs history length  max GC pause (ms)
===================  =================
12500                              0.7
25000                              1.4
50000                              2.8
100000                             5.4
200000                            10.9
400000                            21.8
800000                            46
1600000                           87
3200000                          175
6400000                          350

Mặc dù bạn đang tối ưu hóa độ trễ, tôi cũng nhận thấy các số liệu khác cũng được cải thiện. Trong trường hợp 200000, thời gian thực hiện giảm từ 1,5 giây xuống 0,2 giây và tổng mức sử dụng bộ nhớ giảm từ 600MB xuống 27MB.

Tôi nên lưu ý rằng tôi đã gian lận bằng cách điều chỉnh thiết kế:

  • Tôi đã gỡ bỏ Intkhỏi Msg, vì vậy nó không ở hai nơi.
  • Thay vì sử dụng một bản đồ từ Ints đến ByteStrings, tôi đã sử dụng một Sequencesố ByteStrings, và thay vì một Intcho mỗi tin nhắn, tôi nghĩ rằng nó có thể được thực hiện với một Intcho toàn bộ Sequence. Giả sử các tin nhắn không thể được sắp xếp lại, bạn có thể sử dụng một phần bù duy nhất để dịch thư bạn muốn đến vị trí của nó trong hàng đợi.

(Tôi đã bao gồm một chức năng bổ sung getMsgđể chứng minh điều đó.)

{-# LANGUAGE BangPatterns #-}

import qualified Control.Exception as Exception
import qualified Control.Monad as Monad
import qualified Data.ByteString as ByteString
import Data.Sequence as S

newtype Msg = Msg ByteString.ByteString

data Chan = Chan Int (Seq ByteString.ByteString)

message :: Int -> Msg
message n = Msg (ByteString.replicate 1024 (fromIntegral n))

maxSize :: Int
maxSize = 200000

pushMsg :: Chan -> Msg -> IO Chan
pushMsg (Chan !offset sq) (Msg msgContent) =
    Exception.evaluate $
        let newSize = 1 + S.length sq
            newSq = sq |> msgContent
        in
        if newSize <= maxSize
            then Chan offset newSq
            else
                case S.viewl newSq of
                    (_ :< newSq') -> Chan (offset+1) newSq'
                    S.EmptyL -> error "Can't happen"

getMsg :: Chan -> Int -> Maybe Msg
getMsg (Chan offset sq) i_ = getMsg' (i_ - offset)
    where
    getMsg' i
        | i < 0            = Nothing
        | i >= S.length sq = Nothing
        | otherwise        = Just (Msg (S.index sq i))

main :: IO ()
main = Monad.foldM_ pushMsg (Chan 0 S.empty) (map message [1..5 * maxSize])

4
Chào! Cảm ơn câu trả lời của bạn. Kết quả của bạn chắc chắn vẫn cho thấy sự chậm lại tuyến tính, nhưng thật thú vị khi bạn có được sự tăng tốc như vậy Data.Sequence- chúng tôi đã thử nghiệm điều đó và thấy nó thực sự tồi tệ hơn Data.Map! Tôi không chắc sự khác biệt là gì, vì vậy tôi sẽ phải điều tra ...
jameshfisher

8

Như đã đề cập trong các câu trả lời khác, trình thu gom rác trong dữ liệu trực tiếp GHC, có nghĩa là dữ liệu bạn lưu trữ trong bộ nhớ càng lâu thì thời gian tạm dừng của GC sẽ càng lâu.

GHC 8.2

Để khắc phục vấn đề này một phần, một tính năng gọi là vùng nhỏ gọn đã được giới thiệu trong GHC-8.2. Nó vừa là một tính năng của hệ thống thời gian chạy GHC vừa là thư viện trưng bày giao diện thuận tiện để làm việc. Tính năng vùng nhỏ gọn cho phép đặt dữ liệu của bạn vào một vị trí riêng biệt trong bộ nhớ và GC sẽ không truyền dữ liệu trong giai đoạn thu gom rác. Vì vậy, nếu bạn có một cấu trúc lớn mà bạn muốn giữ trong bộ nhớ, hãy xem xét sử dụng các vùng nhỏ gọn. Tuy nhiên, bản thân khu vực nhỏ gọn không có bộ thu gom rác nhỏ bên trong, nó hoạt động tốt hơn cho các cấu trúc dữ liệu chỉ nối thêm , không phải là HashMapnơi bạn cũng muốn xóa nội dung. Mặc dù bạn có thể khắc phục vấn đề này. Để biết chi tiết tham khảo bài viết trên blog sau:

GHC 8.10

Hơn nữa, kể từ GHC-8.10, một thuật toán thu gom rác gia tăng độ trễ thấp mới được triển khai. Đó là một thuật toán GC thay thế không được bật theo mặc định nhưng bạn có thể chọn tham gia nếu bạn muốn. Vì vậy, bạn có thể chuyển đổi mặc định GC sang một cái mới hơn để tự động nhận các tính năng được cung cấp bởi các vùng nhỏ gọn mà không cần phải thực hiện gói và tháo gói thủ công. Tuy nhiên, GC mới không phải là viên đạn bạc và không giải quyết được tất cả các vấn đề một cách tự động, và nó có sự đánh đổi. Để biết điểm chuẩn của GC mới, hãy tham khảo kho GitHub sau:


3

Chà, bạn đã tìm thấy giới hạn của các ngôn ngữ với GC: Chúng không phù hợp với các hệ thống thời gian thực khó tính.

Bạn có 2 lựa chọn:

1 Tăng kích thước heap và sử dụng hệ thống bộ nhớ đệm 2 cấp, các tin nhắn cũ nhất được gửi vào đĩa và bạn giữ các tin nhắn mới nhất trên bộ nhớ, bạn có thể thực hiện việc này bằng cách sử dụng phân trang OS. Vấn đề, mặc dù với giải pháp này là phân trang có thể tốn kém tùy thuộc vào khả năng đọc của đơn vị bộ nhớ thứ cấp được sử dụng.

Chương trình thứ 2 giải pháp sử dụng 'C' và giao diện với FFI thành haskell. Bằng cách đó bạn có thể làm quản lý bộ nhớ của riêng bạn. Đây sẽ là lựa chọn tốt nhất vì bạn có thể tự mình kiểm soát bộ nhớ bạn cần.


1
Xin chào Fernando. Cảm ơn vì điều đó. Hệ thống của chúng tôi chỉ là "mềm" theo thời gian thực, nhưng trong trường hợp của chúng tôi, chúng tôi thấy rằng GC đã quá trừng phạt ngay cả đối với thời gian thực mềm. Chúng tôi chắc chắn đang nghiêng về giải pháp số 2 của bạn.
jameshfisher
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.