Haskell 프로그램에서 가비지 콜렉션 일시 정지 시간 단축


130

우리는 "메시지"를 수신 및 전달하는 프로그램을 개발하고 있으며, 해당 메시지의 임시 기록을 유지하면서 요청시 메시지 기록을 알려줄 수 있습니다. 메시지는 숫자로 식별되며 일반적으로 크기는 약 1KB이므로 수십만 개의 메시지를 보관해야합니다.

대기 시간을 위해이 프로그램을 최적화하려고합니다. 메시지를주고받는 시간은 10 밀리 초 미만이어야합니다.

이 프로그램은 Haskell로 작성되었으며 GHC로 컴파일되었습니다. 그러나 실제 프로그램에서 100 밀리 초가 넘는 지연 시간 요구 사항에 대해서는 가비지 수집 일시 중지가 너무 길다는 것을 알았습니다.

다음 프로그램은 응용 프로그램의 단순화 된 버전입니다. 를 사용하여 Data.Map.Strict메시지를 저장합니다. 메시지는로 ByteString식별됩니다 Int. 1,000,000 개의 메시지가 숫자 순서대로 삽입되고 기록이 최대 200,000 개의 메시지로 유지되도록 가장 오래된 메시지가 계속 제거됩니다.

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

다음을 사용하여이 프로그램을 컴파일하고 실행했습니다.

$ 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

여기서 중요한 메트릭은 "최대 일시 중지"0.0515 초 또는 51 밀리 초입니다. 우리는 이것을 최소한 10 배 줄이려고합니다.

실험에 따르면 GC 일시 중지 길이는 기록의 메시지 수에 의해 결정됩니다. 관계는 대략 선형이거나 아마도 초 선형입니다. 다음 표는이 관계를 보여줍니다. ( 여기 에서 벤치마킹 테스트일부 차트를 확인할 수 있습니다 .)

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

우리는 이러한 대기 시간을 줄일 수 있는지 여부를 찾기 위해 여러 가지 다른 변수를 실험 해 보았지만 그 중 큰 차이는 없습니다. 이러한 중요하지 않은 변수 중에는 최적화 ( -O, -O2); RTS GC 옵션 ( -G, -H, -A, -c), 코어 (수 -N), 다른 데이터 구조 ( Data.Sequence) 메시지의 크기, 생성 단기 쓰레기의 양. 압도적 인 결정 요인은 기록의 메시지 수입니다.

우리의 작업 이론은 각 GC 사이클이 모든 작업 가능한 메모리를 걸어서 복사해야하기 때문에 메시지 수에서 일시 중지가 선형이라는 것입니다. 이는 분명히 선형 작업입니다.

질문 :

  • 이 선형 시간 이론이 맞습니까? GC 일시 정지 길이를이 간단한 방식으로 표현할 수 있습니까, 아니면 현실이 더 복잡합니까?
  • 작업 메모리에서 GC 일시 정지가 선형이면 관련된 상수 요소를 줄일 수있는 방법이 있습니까?
  • 증분 GC 또는 이와 유사한 옵션이 있습니까? 연구 논문 만 볼 수 있습니다. 지연 시간을 줄이기 위해 처리량을 교환 할 의향이 있습니다.
  • 여러 프로세스로 분할하는 것 외에 작은 GC주기에 대해 메모리를 "파티션"하는 방법이 있습니까?

1
@Bakuriu : 맞습니다. 그러나 10ms는 거의 모든 현대 OS에서 달성 할 수 있어야합니다. 나는 심지어 내 옛날 라즈베리 파이에 간단한 C 프로그램을 실행하면, 그들은 쉽게 5 MS의 범위에서 대기 시간을 달성, 또는 적어도 안정적으로 15 MS 같은.
leftaroundabout

3
테스트 사례가 유용하다고 확신 COntrol.Concurrent.Chan하십니까 (예 : 사용하지 않습니까? 가변 객체가 방정식을 변경 함)? 나는 당신이 어떤 쓰레기를 생성하고 가능한 한 적은 양을 만드는지 (예를 들어, 융합이 일어나도록 시도해보십시오 -funbox-strict) 시도하여 시작하는 것이 좋습니다 . 스트리밍 라이브러리 (iostream, 파이프, 도관, 스트리밍)를 사용하고 performGC더 빈번한 간격으로 직접 호출 해보십시오 .
jberryman

6
만약 당신이 이루고자하는 것이 일정한 공간에서 이루어질 수 있다면, 그 일이 일어나도록 노력함으로써 시작하십시오 (예를 들어 MutableByteArray; GC 의 링 버퍼 는 전혀 관여하지 않을 것입니다)
jberryman

1
가변 구조를 제안하고 최소한의 쓰레기를 만들기 위해주의를 기울이는 사람들에게, 그것은 일시 정지 시간을 지시하는 것처럼 보이는 쓰레기의 양이 아니라 보유 된 크기 라는 것에주의하십시오 . 더 자주 수집하면 강제로 같은 길이의 일시 중지가 더 많이 발생합니다. 편집 : 가변 오프 힙 구조는 흥미로울 수 있지만 많은 경우 작업하기가 그리 재미 있지는 않습니다!
mike

6
이 설명은 확실히 GC 시간이 모든 세대의 힙 크기에서 선형이 될 것임을 시사합니다. 중요한 요소는 보유 된 객체의 크기 (복사)와 객체에 존재하는 포인터의 수 (청소) : ghc.haskell입니다. org / trac / ghc / wiki / Commentary / Rts / Storage / GC / copying
mike

답변:


96

실제로 200Mb 이상의 라이브 데이터로 51ms의 일시 중지 시간을 갖기 위해 꽤 잘하고 있습니다. 내가 작업하는 시스템의 라이브 데이터 양이 절반으로 최대 일시 중지 시간이 더 깁니다.

귀하의 가정이 맞고, 주요 GC 일시 정지 시간은 실시간 데이터의 양에 정비례하지만, 불행히도 GHC를 그대로 사용하는 방법은 없습니다. 우리는 과거에 증분 GC를 실험했지만 연구 프로젝트였으며 출시 된 GHC로 접는 데 필요한 성숙도 수준에 도달하지 못했습니다.

우리가 미래에 이것을 도울 것으로 기대하는 한 가지는 소형 지역입니다 : https://phabricator.haskell.org/D1264 . 힙에서 구조를 압축하는 일종의 수동 메모리 관리이며 GC는이를 가로 지르지 않아도됩니다. 오래 지속되는 데이터에 가장 적합하지만 설정에서 개별 메시지에 사용하기에 충분할 것입니다. 우리는 GHC 8.2.0에서 그것을 목표로하고 있습니다.

분산 설정에 있고 어떤 종류의로드 밸런서가있는 경우 일시 정지 적중을 피하기 위해 사용할 수있는 트릭이 있습니다. 기본적으로로드 밸런서가 곧 주요 GC를 수행하고 물론 요청을받지 않더라도 머신이 여전히 GC를 완료하는지 확인하십시오.


13
안녕하세요 Simon, 자세한 답변에 감사드립니다! 나쁜 소식이지만 폐쇄하는 것이 좋습니다. 우리는 현재 유일하게 적합한 대안 인 가변 구현으로 나아가고 있습니다. 우리가 이해하지 못하는 몇 가지 : (1)로드 밸런싱 체계와 관련된 트릭은 무엇입니까 performGC? (2) 압축이 -c성능을 저하시키는 이유는 무엇 입니까? (3) 컴팩트에 대한 자세한 내용이 있습니까? 그것은 매우 흥미롭게 들리지만 불행히도 우리가 생각하기에는 너무 멀습니다.
jameshfisher


@AlfredoDiNapoli 감사합니다!
mljrg

9

IOVector기본 데이터 구조로 사용하는 링 버퍼 방식으로 코드 스 니펫을 시도했습니다 . 내 시스템 (GHC 7.10.3, 동일한 컴파일 옵션)에서 이로 인해 최대 시간 (OP에서 언급 한 메트릭)이 ~ 22 % 감소했습니다.

NB. 나는 여기에 두 가지 가정을했다.

  1. 변경 가능한 데이터 구조는 문제에 적합합니다 (어쨌든 메시지 전달은 IO를 의미합니다)
  2. 귀하의 메시지 아이디는 지속적입니다

몇 가지 추가 Int매개 변수와 산술을 사용하면 (messageId가 0 또는으로 다시 설정 될 때와 같이 minBound) 특정 메시지가 여전히 히스토리에 있는지 여부를 판별하고 링 버퍼의 해당 색인에서 검색 할 수 있습니다.

테스트의 즐거움을 위해 :

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
안녕하세요! 좋은 대답입니다. GC가 여전히 IOVector각 인덱스에서 및 (불변, GC'd) 값 을 걸어야하기 때문에 이것이 22 % 속도 만 향상되는 이유는 의심합니다 . 현재 가변 구조를 사용하여 다시 구현하기위한 옵션을 조사 중입니다. 링 버퍼 시스템과 비슷할 것입니다. 그러나 자체 메모리 관리를 위해 Haskell 메모리 공간 밖으로 완전히 이동하고 있습니다.
jameshfisher

11
@ jamesfisher : 나는 실제로 비슷한 문제에 직면했지만 하스켈 측에서 mem 관리를 유지하기로 결정했습니다. 이 솔루션은 실제로 링 버퍼로, 단일 연속 메모리 블록에 원본 데이터의 바이트 단위 복사본을 유지하여 단일 하스켈 값을 생성합니다. 이 RingBuffer.hs gist 에서 살펴보십시오 . 샘플 코드에 대해 테스트했으며 임계 지표의 약 90 % 속도가 향상되었습니다. 편하게 코드를 사용하십시오.
mgmeier

8

나는 다른 사람들과 동의해야합니다-당신이 어려운 실시간 제약 조건을 가지고 있다면 GC 언어를 사용하는 것이 이상적이지 않습니다.

그러나 Data.Map이 아닌 다른 사용 가능한 데이터 구조를 실험 해 볼 수도 있습니다.

Data.Sequence를 사용하여 다시 작성하고 유망한 개선 사항을 얻었습니다.

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

대기 시간을 최적화하고 있지만 다른 메트릭도 향상되는 것을 알았습니다. 200000 경우 실행 시간이 1.5 초에서 0.2 초로 줄어들고 총 메모리 사용량이 600MB에서 27MB로 줄어 듭니다.

디자인을 조정하여 속이는 것에 주목해야합니다.

  • 나는 제거 Int로부터를 Msg그래서 두 곳에 아니다.
  • 대신에서지도를 사용하는 Int을의 ByteString의, 내가 사용 SequenceByteString들, 대신 하나의 Int메시지 당, 나는 그것이 하나와 함께 할 수 있다고 생각 Int전체에 대한 Sequence. 메시지를 재정렬 할 수 없다고 가정하면 단일 오프셋을 사용하여 큐에서 메시지를 보관할 위치로 변환 할 수 있습니다.

(저는이 getMsg를 입증하기 위해 추가 기능 을 포함 시켰습니다 .)

{-# 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
안녕하세요! 답변 주셔서 감사합니다. 결과는 여전히 선형 속도 저하를 보여 주지만, 이러한 속도 향상에서 얻은 사실은 흥미 롭습니다. Data.Sequence테스트 한 결과 실제로 Data.Map보다 더 나쁩니다! 차이점이 무엇인지 잘 모르겠으므로 조사해야합니다.
jameshfisher

8

다른 답변에서 언급했듯이 GHC의 가비지 수집기는 라이브 데이터를 트래버스합니다. 즉, 메모리에 오래 저장되는 데이터가 많을수록 GC 일시 중지 시간이 길어집니다.

GHC 8.2

이 문제를 부분적으로 극복하기 위해 GHC-8.2 에는 컴팩트 영역 이라는 기능 이 도입되었습니다. GHC 런타임 시스템의 기능이며 작업하기 편리한 인터페이스를 제공 하는 라이브러리 입니다. 컴팩트 영역 기능을 사용하면 데이터를 메모리의 별도 위치에 배치 할 수 있으며 GC는 가비지 수집 단계에서 데이터를 통과하지 않습니다. 따라서 메모리에 유지하려는 구조가 큰 경우 컴팩트 영역 사용을 고려하십시오. 그러나 컴팩트 영역 자체 에는 미니 가비지 콜렉터 가 없으므로 항목 을 삭제하려는 위치가 아닌 추가 전용 데이터 구조에 더 적합 HashMap합니다. 이 문제를 극복 할 수는 있지만 자세한 내용은 다음 블로그 게시물을 참조하십시오.

GHC 8.10

또한 GHC-8.10 이후로 새로운 지연 시간이 짧은 증분 가비지 수집기 알고리즘이 구현되었습니다. 기본적으로 활성화되어 있지 않지만 원하는 경우이를 옵트 인 할 수있는 대체 GC 알고리즘입니다. 따라서 수동 포장 및 포장 풀기없이 컴팩트 한 영역에서 제공하는 기능을 자동으로 가져 오기 위해 기본 GC를 최신 버전으로 전환 할 수 있습니다 . 그러나 새로운 GC는 은색 총알이 아니며 모든 문제를 자동으로 해결하지는 않으며 그 단점이 있습니다. 새로운 GC의 벤치 마크는 다음 GitHub 리포지토리를 참조하십시오.


3

글쎄, 당신은 GC 언어의 한계를 발견했다 : 그들은 하드 코어 실시간 시스템에 적합하지 않다.

두 가지 옵션이 있습니다.

첫 번째 힙 크기를 늘리고 2 레벨 캐싱 시스템을 사용하십시오. 가장 오래된 메시지는 디스크로 전송되며 최신 메시지는 메모리에 보관합니다. OS 페이징을 사용하여이를 수행 할 수 있습니다. 그러나이 솔루션의 문제점은 사용 된 보조 메모리 장치의 읽기 기능에 따라 페이징이 비쌀 수 있다는 것입니다.

'C'를 사용하여 솔루션을 프로그램하고 FFI와 인터페이스하여 haskell. 그렇게하면 자신 만의 메모리 관리를 할 수 있습니다. 필요한 메모리를 직접 제어 할 수 있으므로이 방법이 가장 좋습니다.


1
안녕 페르난도 고마워 우리 시스템은 "부드러운"실시간이지만, 우리의 경우에는 부드러운 실시간으로도 GC가 너무 처벌 적이라는 것을 알았습니다. 우리는 귀하의 # 2 솔루션에 확실히 기대하고 있습니다.
jameshfisher
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.