हास्केल कार्यक्रम में कचरा-संग्रह ठहराव का समय कम करना


130

हम एक कार्यक्रम विकसित कर रहे हैं जो उन संदेशों का अस्थायी इतिहास रखते हुए "संदेश" प्राप्त करता है और आगे बढ़ाता है, ताकि अनुरोध किए जाने पर यह आपको संदेश इतिहास बता सके। संदेशों को संख्यात्मक रूप से पहचाना जाता है, आमतौर पर आकार में लगभग 1 किलोबाइट होते हैं, और हमें इन सैकड़ों संदेशों को रखने की आवश्यकता होती है।

हम इस कार्यक्रम को विलंबता के लिए अनुकूलित करना चाहते हैं: संदेश भेजने और प्राप्त करने के बीच का समय 10 मिलीसेकंड से कम होना चाहिए।

कार्यक्रम हास्केल में लिखा गया है और जीएचसी के साथ संकलित किया गया है। हालांकि, हमने पाया है कि कचरा संग्रह ठहराव हमारी विलंबता आवश्यकताओं के लिए बहुत लंबा है: हमारे वास्तविक दुनिया के कार्यक्रम में 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 मिलीसेकंड का "अधिकतम ठहराव" है। हम इसे कम से कम परिमाण के क्रम से कम करना चाहते हैं।

प्रयोग से पता चलता है कि जीसी ठहराव की लंबाई इतिहास में संदेशों की संख्या से निर्धारित होती है। संबंध मोटे तौर पर रैखिक, या शायद सुपर-रैखिक है। निम्न तालिका इस रिश्ते को दिखाती है। ( आप हमारे बेंचमार्किंग टेस्ट यहाँ और कुछ चार्ट यहाँ देख सकते हैं ।)

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); आरटीएस जीसी विकल्प ( -G, -H, -A, -c), कोर (की संख्या -N), विभिन्न डेटा संरचनाओं ( Data.Sequence), संदेशों के आकार, और उत्पन्न अल्पकालिक कचरा की राशि। भारी निर्धारण कारक इतिहास में संदेशों की संख्या है।

हमारा कार्य सिद्धांत यह है कि संदेशों की संख्या में ठहराव रैखिक होते हैं क्योंकि प्रत्येक जीसी चक्र को सभी काम करने योग्य सुलभ मेमोरी पर चलना पड़ता है और इसे कॉपी करना पड़ता है, जो स्पष्ट रूप से रैखिक संचालन हैं।

प्रशन:

  • क्या यह रैखिक-समय सिद्धांत सही है? क्या जीसी ठहराव की लंबाई इस सरल तरीके से व्यक्त की जा सकती है, या वास्तविकता अधिक जटिल है?
  • यदि जीसी पॉज वर्किंग मेमोरी में रैखिक है, तो क्या इसमें शामिल कारकों को कम करने का कोई तरीका है?
  • वृद्धिशील जीसी के लिए कोई विकल्प हैं, या ऐसा कुछ भी है? हम केवल शोध पत्र देख सकते हैं। हम कम विलंबता के लिए थ्रूपुट का व्यापार करने के लिए बहुत इच्छुक हैं।
  • क्या कई प्रक्रियाओं में विभाजित होने के अलावा, छोटे, जीसी चक्रों के लिए मेमोरी "विभाजन" करने के कोई तरीके हैं?

1
@ बकुरी: सही है, लेकिन 10 एमएस को किसी भी ट्विस्ट के बिना किसी भी आधुनिक ओएस के साथ प्राप्त करना चाहिए। जब मैं अपने पुराने रास्पबेरी पी पर भी सरलीकृत सी कार्यक्रम चलाता हूं, तो वे आसानी से 5 एमएस की सीमा में विलंबता प्राप्त करते हैं, या कम से कम मज़बूती से 15 एमएस जैसे कुछ।
बाएंटाउनआउट

3
क्या आप आश्वस्त हैं कि आपका परीक्षण-मामला उपयोगी है (जैसे आप COntrol.Concurrent.Chanउदाहरण के लिए उपयोग नहीं कर रहे हैं ? मेरा सुझाव है कि आप यह सुनिश्चित करके शुरू करें कि आप जो कचरा पैदा कर रहे हैं और जितना संभव हो उतना कम बना रहे हैं (उदाहरण के लिए सुनिश्चित करें कि फ्यूजन होता है, कोशिश करें -funbox-strict)। शायद एक स्ट्रीमिंग लिब (iostreams, पाइप, नाली, स्ट्रीमिंग) का उपयोग करके और performGCअधिक लगातार अंतराल पर सीधे कॉल करने का प्रयास करें।
जिब्रीमैन

6
यदि आप जो पूरा करने की कोशिश कर रहे हैं वह निरंतर स्थान पर किया जा सकता है, तो ऐसा करने की कोशिश करके शुरू करें (जैसे कि शायद एक रिंग बफर ए से MutableByteArray; जीसी उस मामले में बिल्कुल भी शामिल नहीं होगा)
jberryman

1
उत्परिवर्तित संरचनाओं का सुझाव देने वालों और न्यूनतम कचरा बनाने के लिए ध्यान रखने के लिए, ध्यान दें कि यह बनाए रखा गया आकार है, न कि एकत्र किए गए कचरे की मात्रा जो ठहराव के समय को निर्धारित करती है। एक ही लंबाई के बारे में अधिक ठहराव के परिणामस्वरूप अधिक लगातार संग्रह के लिए मजबूर करना। संपादित करें: म्यूटेबल ऑफ-हीप संरचनाएं दिलचस्प हो सकती हैं, लेकिन कई मामलों में काम करने के लिए लगभग इतना मज़ा नहीं है!
माइक

6
यह विवरण निश्चित रूप से बताता है कि जीसी समय सभी पीढ़ियों के लिए ढेर के आकार में रैखिक होगा, महत्वपूर्ण कारक बनाए रखने वाले वस्तुओं के आकार (नकल के लिए) और उनके लिए मौजूदा पॉइंटर्स की संख्या ( स्कैवेंजिंग के
माइक

जवाबों:


96

आप वास्तव में बहुत अच्छी तरह से कर रहे हैं एक 51ms ठहराव समय 200Mb से अधिक लाइव डेटा के साथ। जिस सिस्टम पर मैं काम करता हूं, उसमें लाइव डेटा की आधी मात्रा के साथ एक बड़ा अधिकतम ठहराव समय होता है।

आपकी धारणा सही है, प्रमुख जीसी ठहराव समय लाइव डेटा की मात्रा के सीधे आनुपातिक है, और दुर्भाग्य से जीएचसी के साथ इसके आसपास कोई रास्ता नहीं है क्योंकि यह खड़ा है। हमने अतीत में वृद्धिशील जीसी के साथ प्रयोग किया था, लेकिन यह एक शोध परियोजना थी और रिलीज किए गए जीएचसी में इसे मोड़ने के लिए आवश्यक परिपक्वता के स्तर तक नहीं पहुंची।

एक चीज जो हम उम्मीद कर रहे हैं कि भविष्य में इसके साथ मदद मिलेगी कॉम्पैक्ट क्षेत्र हैं: https://phabricator.haskell.org/D1264 । यह एक प्रकार का मैनुअल मेमोरी मैनेजमेंट है, जहां आप एक संरचना को ढेर में रखते हैं, और जीसी को इसे पार नहीं करना पड़ता है। यह लंबे समय तक रहने वाले डेटा के लिए सबसे अच्छा काम करता है, लेकिन शायद यह आपकी सेटिंग में व्यक्तिगत संदेशों के लिए उपयोग करने के लिए पर्याप्त होगा। हम इसे GHC 8.2.0 में रखना चाहते हैं।

यदि आप एक वितरित सेटिंग में हैं और किसी तरह का लोड-बैलेंसर है, तो ऐसे ट्रिक्स हैं जिनसे आप पॉज़ हिट लेने से बचने के लिए खेल सकते हैं, आप मूल रूप से यह सुनिश्चित करते हैं कि लोड-बैलेंसर उन मशीनों के लिए अनुरोध नहीं भेजता है जो लगभग होने वाली हैं एक प्रमुख GC करें, और निश्चित रूप से सुनिश्चित करें कि मशीन अभी भी GC पूरा करती है, भले ही उसे अनुरोध नहीं मिल रहे हों।


13
हाय साइमन, आपके विस्तृत उत्तर के लिए बहुत बहुत धन्यवाद! यह बुरी खबर है, लेकिन बंद होना अच्छा है। वर्तमान में हम केवल उपयुक्त विकल्प होने के कारण एक परिवर्तनशील कार्यान्वयन की ओर बढ़ रहे हैं। कुछ चीजें जो हमें समझ में नहीं आती हैं: (1) लोड बैलेंसिंग स्कीम में क्या तरकीबें शामिल हैं - क्या वे मैनुअल शामिल हैं performGC? (२) -cबुरा प्रदर्शन करने के कारण कॉम्पैक्टिंग क्यों होती है - हमें लगता है क्योंकि इसमें कई चीजें नहीं मिलती हैं जो इसे अंदर छोड़ सकती हैं? (३) क्या काम्पैक्ट्स के बारे में कोई और जानकारी है? यह बहुत दिलचस्प लगता है लेकिन दुर्भाग्य से यह भविष्य में हमारे लिए थोड़ा बहुत दूर है।
jameshfisher

2
@mljrg आपको अच्छी तरह से रुचि हो सकती है- typed.com/blog/2019/10/nonmoving-gc-merge
अल्फ्रेडो डी नापोली

@AlfredoDiNapoli धन्यवाद!
mljrg

9

मैंने आपके कोड स्निपेट को रिंगबफ़र दृष्टिकोण के साथ IOVectorअंतर्निहित डेटा संरचना के रूप में उपयोग करने की कोशिश की है । मेरे सिस्टम पर (जीएचसी 7.10.3, समान संकलन विकल्प) इसके परिणामस्वरूप अधिकतम समय (आपके ओपी में उल्लिखित मीट्रिक) में 22% की कमी आई है।

एनबी। मैंने यहाँ दो धारणाएँ बनाई हैं:

  1. एक परिवर्तनशील डेटा संरचना समस्या के लिए ठीक है (मुझे लगता है कि संदेश गुजरने का अर्थ है किसी भी तरह IO)
  2. आपके मैसेजआईड निरंतर हैं

कुछ अतिरिक्त Intपैरामीटर और अंकगणित के साथ (जैसे जब मैसेजआईडी 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
नमस्ते! अच्छा उत्तर। मुझे संदेह है कि इसका कारण केवल 22% स्पीडअप है क्योंकि जीसी को अभी भी चलना है IOVectorऔर प्रत्येक इंडेक्स पर (अपरिवर्तनीय, जीसीएलडी) मूल्यों को चलना होगा । वर्तमान में हम परिवर्तनशील संरचनाओं का उपयोग करके पुन: लागू करने के विकल्पों की जांच कर रहे हैं। यह आपके रिंग बफर सिस्टम के समान होने की संभावना है। लेकिन हम इसे अपने मैनुअल मेमोरी मैनेजमेंट को करने के लिए इसे पूरी तरह से हास्केल मेमोरी स्पेस के बाहर ले जा रहे हैं।
jameshfisher

11
@ जैम्सफिशर: मैं वास्तव में इसी तरह की समस्या का सामना कर रहा था, लेकिन हास्केल पक्ष में मेम प्रबंधन रखने का फैसला किया। समाधान वास्तव में एक रिंग बफ़र था, जो मूल डेटा की एक एकल, निरंतर मेमोरी में एक बाइटवाइज़ कॉपी रखता है, जिसके परिणामस्वरूप एकल हास्केल मूल्य होता है। इस RingBuffer.hs gist में इसे देखें । मैंने इसे आपके नमूना कोड के खिलाफ परीक्षण किया, और इसमें लगभग 90% महत्वपूर्ण मीट्रिक का स्पीडअप था। अपनी सुविधानुसार कोड का उपयोग करने के लिए स्वतंत्र महसूस करें।
mgmeier

8

मुझे दूसरों के साथ सहमत होना होगा - यदि आपके पास कठिन वास्तविक समय की बाधाएं हैं, तो जीसी भाषा का उपयोग करना आदर्श नहीं है।

हालाँकि, आप केवल 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.5s से 0.2s तक हो जाता है, और कुल मेमोरी उपयोग 600MB से 27MB तक हो जाता है।

मुझे ध्यान देना चाहिए कि मैंने डिजाइन को तोड़ मरोड़ कर धोखा दिया:

  • मैंने इसमें Intसे निकाल दिया Msg, इसलिए यह दो स्थानों पर नहीं है।
  • से एक मानचित्र उपयोग करने के बजाय Intकरने के लिए रों ByteStringरों, मैं एक प्रयोग किया जाता है Sequenceकी ByteStringहै, और बजाय एक के 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

जैसा कि अन्य उत्तरों में उल्लेख किया गया है, जीएचसी में कचरा संग्रहकर्ता ने लाइव डेटा का अर्थ है, जिसका अर्थ है कि आपके द्वारा मेमोरी में संग्रहीत किए जाने वाले अधिक लंबे समय तक रहने वाले डेटा, लंबे समय तक जीसी पॉज़ होंगे।

जीएचसी 8.2

आंशिक रूप से इस समस्या को दूर करने के लिए, GHC-8.2 में कॉम्पैक्ट क्षेत्रों नामक एक सुविधा शुरू की गई थी। यह जीएचसी रनटाइम सिस्टम की एक विशेषता और एक पुस्तकालय है जो सुविधाजनक इंटरफेस को उजागर करता है । कॉम्पैक्ट क्षेत्र की सुविधा आपके डेटा को मेमोरी में एक अलग स्थान पर रखने की अनुमति देती है और जीसी कचरा संग्रहण चरण के दौरान इसे पार नहीं करेगा। इसलिए यदि आपके पास एक बड़ी संरचना है जिसे आप स्मृति में रखना चाहते हैं, तो कॉम्पैक्ट क्षेत्रों का उपयोग करने पर विचार करें। हालाँकि, कॉम्पैक्ट क्षेत्र में ही मिनी कचरा संग्रहकर्ता नहीं है , यह एपेंड-ओनली डेटा संरचनाओं के लिए बेहतर काम करता है , न कि कुछ ऐसा, जहाँ आप सामान को हटाना चाहते हैं। हालांकि आप इस समस्या को दूर कर सकते हैं। जानकारी के लिए निम्नलिखित ब्लॉग पोस्ट देखें:HashMap

जीएचसी 8.10

इसके अलावा, GHC-8.10 के बाद से एक नया कम विलंबता वृद्धिशील कचरा संग्राहक एल्गोरिथ्म लागू किया गया है। यह एक वैकल्पिक GC एल्गोरिथ्म है जो डिफ़ॉल्ट रूप से सक्षम नहीं है लेकिन आप चाहें तो इसे ऑप्ट-इन कर सकते हैं। इसलिए आप मैन्युअल रूप से रैपिंग और अलॉकिंग करने के लिए बिना कॉम्पैक्ट क्षेत्रों द्वारा प्रदान की जाने वाली सुविधाओं को स्वचालित रूप से प्राप्त करने के लिए डिफ़ॉल्ट जीसी को एक नए पर स्विच कर सकते हैं । हालाँकि, नया GC एक सिल्वर बुलेट नहीं है और सभी समस्याओं को स्वचालित रूप से हल नहीं करता है, और इसके व्यापार-बंद हैं। नए GC के मानदंड के लिए निम्नलिखित GitHub रिपॉजिटरी को देखें:


3

अच्छी तरह से आपको जीसी के साथ भाषाओं की सीमा मिली: वे कट्टर वास्तविक समय प्रणालियों के लिए फिट नहीं हैं।

आपके पास 2 विकल्प हैं:

1 ढेर का आकार बढ़ाएं और 2 स्तर के कैशिंग सिस्टम का उपयोग करें, सबसे पुराने संदेश डिस्क पर भेजे जाते हैं और आप मेमोरी पर नवीनतम संदेश रखते हैं, आप ओएस पेजिंग का उपयोग करके ऐसा कर सकते हैं। हालांकि, इस समाधान के साथ समस्या यह है कि उपयोग की जाने वाली माध्यमिक मेमोरी यूनिट की रीडिंग क्षमताओं के आधार पर पेजिंग महंगी हो सकती है।

दूसरा प्रोग्राम जो 'C' का उपयोग करके हल करता है और इसे FFI के साथ हैस्केल में इंटरफ़ेस करता है। इस तरह आप अपना खुद का मेमोरी मैनेजमेंट कर सकते हैं। यह सबसे अच्छा विकल्प होगा क्योंकि आप अपनी ज़रूरत की मेमोरी को नियंत्रित कर सकते हैं।


1
हाय फर्नांडो। इसके लिए धन्यवाद। हमारा सिस्टम केवल "सॉफ्ट" रियल-टाइम है, लेकिन हमारे मामले में हमने GC को सॉफ्ट रियल-टाइम के लिए भी दंडित किया है। हम निश्चित रूप से आपके # 2 समाधान की ओर झुक रहे हैं।
jameshfisher
हमारी साइट का प्रयोग करके, आप स्वीकार करते हैं कि आपने हमारी Cookie Policy और निजता नीति को पढ़ और समझा लिया है।
Licensed under cc by-sa 3.0 with attribution required.