Haskell programında çöp toplama duraklama süresinin azaltılması


130

"Mesajları" alan ve ileten, bu mesajların geçici bir geçmişini tutarken, istenirse size mesaj geçmişini söyleyebilecek bir program geliştiriyoruz. Mesajlar sayısal olarak tanımlanır, tipik olarak yaklaşık 1 kilobayt boyutundadır ve bu mesajlardan yüz binlerce saklamamız gerekir.

Bu programı gecikme için optimize etmek istiyoruz: mesaj gönderme ve alma arasındaki süre 10 milisaniyenin altında olmalıdır.

Program Haskell'de yazılmıştır ve GHC ile derlenmiştir. Ancak, çöp toplama duraklamalarının gecikme gereksinimlerimiz için çok uzun olduğunu bulduk: gerçek dünya programımızda 100 milisaniyenin üzerinde.

Aşağıdaki program, uygulamamızın basitleştirilmiş bir sürümüdür. Data.Map.StrictMesajları saklamak için a kullanır . Mesajlar bir ile ByteStringtanımlanır Int. Artan sayısal sırada 1.000.000 mesaj eklenir ve en eski mesajlar, geçmişi maksimum 200.000 mesajda tutmak için sürekli olarak kaldırılır.

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])

Bu programı kullanarak derledik ve çalıştırdık:

$ 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

Buradaki önemli metrik, 0,0515 sn veya 51 milisaniye "maksimum duraklamadır". Bunu en azından bir dereceye kadar azaltmak istiyoruz.

Deney, bir GC duraklamasının uzunluğunun geçmişteki mesajların sayısına göre belirlendiğini gösterir. İlişki kabaca doğrusal veya belki de süper doğrusaldır. Aşağıdaki tablo bu ilişkiyi göstermektedir. ( Kıyaslama testlerimizi burada ve bazı grafikleri burada görebilirsiniz .)

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

Bu gecikmeyi azaltıp azaltamayacaklarını bulmak için başka değişkenlerle deneyler yaptık, bunların hiçbiri büyük bir fark yaratmıyor. Bu önemsiz değişkenler arasında şunlar vardır: optimizasyon ( -O, -O2); RTS GC seçenekleri ( -G, -H, -A, -c), çekirdek sayısı ( -N), farklı bir veri yapıları ( Data.Sequence), mesajların boyutunu ve oluşturulan kısa ömürlü çöp miktarı. En önemli belirleyici faktör, geçmişteki mesajların sayısıdır.

Çalışma teorimiz, duraklamaların mesaj sayısında doğrusal olmasıdır, çünkü her bir GC döngüsü çalışan erişilebilir tüm hafızanın üzerinde yürümek ve onu kopyalamak zorundadır ki bunlar açıkça doğrusal işlemlerdir.

Sorular:

  • Bu doğrusal zaman teorisi doğru mu? GC duraklamalarının uzunluğu bu basit şekilde ifade edilebilir mi, yoksa gerçeklik daha karmaşık mı?
  • GC duraklaması çalışma belleğinde doğrusal ise, ilgili sabit faktörleri azaltmanın herhangi bir yolu var mı?
  • Artımlı GC için herhangi bir seçenek veya buna benzer herhangi bir seçenek var mı? Sadece araştırma makalelerini görebiliriz. Daha düşük gecikme süresi için iş hacmi ticareti yapmaya çok istekliyiz.
  • Belleği daha küçük, GC döngüleri için, birden çok işleme bölmek dışında "bölümlemenin" herhangi bir yolu var mı?

1
@Bakuriu: doğru, ancak 10 ms'ye hemen hemen her modern işletim sistemi ile herhangi bir ayar yapmadan ulaşılabilir. Basit C programları çalıştırdığımda, eski Raspberry pi'mde bile, 5 ms aralığında veya en azından 15 ms gibi güvenilir bir şekilde gecikmeler elde ediyorlar .
leftaroundabout

3
Test durumunuzun yararlı olduğundan emin misiniz ( COntrol.Concurrent.Chanörneğin kullanmadığınız gibi? Değişken nesneler denklemi değiştirir)? Hangi çöpü ürettiğinizi bildiğinizden ve mümkün olduğunca az yaparak başlamanızı öneririm (örneğin, füzyonun gerçekleştiğinden emin olun, deneyin -funbox-strict). Belki bir akış kitaplığı (iostreams, borular, kanal, akış) kullanmayı ve performGCdaha sık aralıklarla doğrudan arama yapmayı deneyebilirsiniz .
jberryman

6
Eğer başarmaya çalıştığınız şey sabit bir alanda yapılabiliyorsa, bunu gerçekleştirmeye çalışarak başlayın (örneğin MutableByteArray,
a'dan

1
Değişken yapılar öneren ve minimum çöp oluşturmaya özen gösterenlere, duraklama süresini belirleyen görünenin toplanan çöp miktarı değil, tutulan boyut olduğunu unutmayın . Daha sık koleksiyonları zorlamak, yaklaşık aynı uzunlukta daha fazla duraklamaya neden olur. Düzenleme: Değişken yığın dışı yapılar ilginç olabilir, ancak çoğu durumda çalışmak o kadar da eğlenceli değildir!
mike

6
Bu açıklama kesinlikle GC zamanının tüm nesiller için yığın boyutunda doğrusal olacağını, önemli faktörlerin tutulan nesnelerin boyutu (kopyalama için) ve bunlarda mevcut olan işaretçilerin sayısı (süpürme için) olduğunu önermektedir
mike

Yanıtlar:


96

Aslında, 200 Mb'den fazla canlı veri ile 51 ms'lik bir duraklama süresine sahip olmak oldukça iyi gidiyor. Üzerinde çalıştığım sistem, yarısı kadar canlı veri ile daha büyük bir maksimum duraklatma süresine sahip.

Varsayımınız doğrudur, ana GC duraklatma süresi, canlı veri miktarı ile doğru orantılıdır ve ne yazık ki, GHC'nin olduğu haliyle bunun etrafında bir yol yoktur. Geçmişte artımlı GC ile deneyler yaptık, ancak bu bir araştırma projesiydi ve onu yayınlanan GHC'ye katlamak için gereken olgunluk seviyesine ulaşmadı.

Gelecekte buna yardımcı olacağını umduğumuz bir şey de kompakt bölgelerdir: https://phabricator.haskell.org/D1264 . Yığın içindeki bir yapıyı sıkıştırdığınız ve GC'nin onu geçmek zorunda olmadığı bir tür manüel bellek yönetimi. Uzun ömürlü veriler için en iyi sonucu verir, ancak belki de ortamınızdaki tek tek mesajlar için kullanmak yeterince iyi olacaktır. GHC 8.2.0'da olmasını hedefliyoruz.

Dağıtılmış bir ayardaysanız ve bir tür yük dengeleyiciniz varsa, duraklatma vuruşunu önlemek için oynayabileceğiniz püf noktaları vardır, temel olarak yük dengeleyicinin, büyük bir GC yapın ve tabii ki, istek almadığı halde makinenin GC'yi tamamladığından emin olun.


13
Merhaba Simon, detaylı cevabınız için çok teşekkür ederim! Bu kötü haber, ancak kapanışa sahip olmak güzel. Şu anda tek uygun alternatif olan değişken bir uygulamaya doğru ilerliyoruz. Anlamadığımız birkaç şey: (1) Yük dengeleme şemasında yer alan püf noktaları nelerdir - bunlar el kitabını içeriyor performGCmu? (2) Kompaktlama neden -cdaha kötü performans gösteriyor - yerinde bırakabileceği pek çok şey bulamadığı için varsayıyoruz? (3) Kompaktlar hakkında daha fazla ayrıntı var mı? Kulağa çok ilginç geliyor ama maalesef gelecekte düşünmemiz için biraz fazla uzak.
jameshfisher

2
@mljrg well-typed.com/blog/2019/10/nonmoving-gc-merge ile ilgilenebilirsin
Alfredo Di Napoli

@AlfredoDiNapoli Teşekkürler!
mljrg

9

Kod pasajınızı IOVector, temel veri yapısı olarak kullanarak bir zil tamponu yaklaşımıyla denedim . Benim sistemimde (GHC 7.10.3, aynı derleme seçenekleri) bu, maksimum sürenin (OP'nizde bahsettiğiniz ölçü) ~% 22 oranında azalmasına neden oldu.

NB. Burada iki varsayım yaptım:

  1. Değişken bir veri yapısı problem için uygundur (sanırım mesaj geçişi IO anlamına gelir)
  2. Mesaj kimlikleriniz süreklidir

Bazı ek Intparametre ve aritmetik ile (mesaj kimliği sıfırlandığında veya sıfırlandığında olduğu gibi minBound), belirli bir mesajın hala geçmişte olup olmadığını belirlemek ve onu zil arabelleğindeki karşılık gelen dizinden almak kolay olmalıdır.

Test zevkiniz için:

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
Selam! Güzel cevap. Bunun yalnızca% 22'lik bir hızlanma elde etmesinin nedeninin, GC'nin hala IOVectorher dizindeki ve (değişmez, GC'd) değerlerini yürütmesi gerektiğinden şüpheleniyorum . Şu anda değiştirilebilir yapılar kullanarak yeniden uygulama seçeneklerini araştırıyoruz. Halka tampon sisteminize benzer olması muhtemeldir. Ancak kendi manuel bellek yönetimimizi yapmak için onu tamamen Haskell bellek alanının dışına taşıyoruz.
jameshfisher

11
@jamesfisher: Aslında benzer bir sorunla karşı karşıyaydım, ancak mem yönetimini Haskell tarafında tutmaya karar verdim. Çözüm aslında orijinal verilerin bir kopyasını tek ve sürekli bir bellek bloğunda tutan ve böylece tek bir Haskell değeriyle sonuçlanan bir halka tampondu. RingBuffer.hs'nin özetinde buna bir göz atın . Örnek kodunuzla test ettim ve kritik metriğin yaklaşık% 90'ı kadar bir hız elde ettim. Kodu istediğiniz zaman kullanmaktan çekinmeyin.
mgmeier

8

Diğerleriyle aynı fikirdeyim - gerçek zamanlı kısıtlamalarınız varsa, o zaman bir GC dili kullanmak ideal değildir.

Bununla birlikte, yalnızca DataMap yerine diğer mevcut veri yapılarını denemeyi düşünebilirsiniz.

Data.Sequence kullanarak yeniden yazdım ve bazı umut verici iyileştirmeler yaptım:

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

Gecikme için optimize ediyor olsanız da, diğer ölçümlerin de geliştiğini fark ettim. 200000 durumunda, yürütme süresi 1.5 saniyeden 0.2 saniyeye ve toplam bellek kullanımı 600MB’den 27MB’ye düşer.

Tasarımı değiştirerek hile yaptığımı not etmeliyim:

  • Ben kaldırıldı Intdan Msgyüzden iki yerde değil.
  • S'den Ints'ye bir Harita kullanmak yerine, ByteStringa Sequenceve ByteStrings kullandım ve Intmesaj başına bir tane yerine Int, bütün için bir tane ile yapılabileceğini düşünüyorum Sequence. Mesajların yeniden sıralanamayacağını varsayarsak, hangi mesajı kuyruğun bulunduğu yere çevirmek için tek bir ofset kullanabilirsiniz.

(Bunu getMsggöstermek için ek bir işlev ekledim.)

{-# 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
Selam! Cevabınız için teşekkürler. Sonuçlarınız kesinlikle hala doğrusal yavaşlamayı gösteriyor, ancak bu kadar hızlı bir şekilde hızlanmanız oldukça ilginç Data.Sequence- bunu test ettik ve aslında Data.Map'ten daha kötü olduğunu gördük!
Aradaki

8

Diğer yanıtlarda belirtildiği gibi, GHC'deki çöp toplayıcı canlı verileri çaprazlar; bu, bellekte ne kadar uzun ömürlü veri depolarsanız, GC duraklamalarının o kadar uzun olacağı anlamına gelir.

GHC 8.2

Bu sorunu kısmen aşmak için GHC-8.2'de kompakt bölgeler adı verilen bir özellik tanıtıldı. Hem GHC çalışma zamanı sisteminin bir özelliği hem de çalışmak için uygun bir arayüz sunan bir kitaplıktır . Kompakt bölgeler özelliği, verilerinizi bellekte ayrı bir yere koymanıza izin verir ve GC, çöp toplama aşamasında onu geçmez. Bu nedenle, bellekte tutmak istediğiniz büyük bir yapınız varsa, kompakt bölgeler kullanmayı düşünün. Ancak, kompakt bölge kendisi yok Mini çöp toplayıcı bunun için daha iyi çalışır, içini Yalnızca ekleme veri yapıları değil, böyle bir şey HashMapde silme şeyler istediğiniz yere. Yine de bu sorunun üstesinden gelebilirsiniz. Ayrıntılar için aşağıdaki blog gönderisine bakın:

GHC 8.10

Ayrıca, GHC-8.10'dan beri yeni bir düşük gecikmeli artımlı çöp toplayıcı algoritması uygulanmaktadır. Varsayılan olarak etkinleştirilmemiş alternatif bir GC algoritmasıdır, ancak isterseniz bunu seçebilirsiniz. Böylece, manuel sarma ve açma işlemi yapmanıza gerek kalmadan kompakt bölgeler tarafından sağlanan özellikleri otomatik olarak almak için varsayılan GC'yi daha yenisine geçirebilirsiniz. Bununla birlikte, yeni GC, sihirli bir değnek değildir ve tüm sorunları otomatik olarak çözmez ve kendi ödünleşimlerine sahiptir. Yeni GC'nin karşılaştırmaları için aşağıdaki GitHub deposuna bakın:


3

GC ile dillerin sınırlarını buldunuz: Bunlar, gerçek zamanlı sistemler için uygun değiller.

2 seçeneğiniz var:

1. Yığın boyutunu artırın ve 2 seviyeli bir önbellekleme sistemi kullanın, en eski mesajlar diske gönderilir ve en yeni mesajları hafızada tutarsınız, bunu OS sayfalama kullanarak yapabilirsiniz. Ancak bu çözümde sorun, kullanılan ikincil bellek biriminin okuma yeteneklerine bağlı olarak sayfalamanın pahalı olabilmesidir.

2. Bu çözümü 'C' kullanarak programlayın ve haskell'e FFI ile arayüzleyin. Bu şekilde kendi hafıza yönetiminizi yapabilirsiniz. İhtiyacınız olan belleği kendiniz kontrol edebileceğiniz için bu en iyi seçenek olacaktır.


1
Merhaba Fernando. Bunun için teşekkürler. Sistemimiz yalnızca "yumuşak" gerçek zamanlıdır, ancak bizim durumumuzda GC'nin yumuşak gerçek zamanlı için bile çok cezalandırıcı olduğunu gördük. Kesinlikle 2. çözümünüze eğiliyoruz.
jameshfisher
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.