2009-10-30 7 views
9

Według http://www.codeguru.com/forum/showthread.php?t=463663, C# 's getHashCode funkcja w 3.5 jest zaimplementowany jako:Szybsze String GetHashCode (np użyciu wielordzeniowych lub GPU)

public override unsafe int GetHashCode() 
{ 
    fixed (char* str = ((char*) this)) 
    { 
     char* chPtr = str; 
     int num = 0x15051505; 
     int num2 = num; 
     int* numPtr = (int*) chPtr; 
     for (int i = this.Length; i > 0; i -= 4) 
     { 
      num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
      if (i <= 2) 
      { 
       break; 
      } 
      num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; 
      numPtr += 2; 
     } 
     return (num + (num2 * 0x5d588b65)); 
    } 
} 

Jestem ciekaw czy ktoś może pochodzić z funkcji, która zwraca te same rezultaty, ale jest szybszy. Można zwiększyć ogólny nakład początkowy i nakład połowowy głównej aplikacji. Wymaganie jednorazowej inicjalizacji (na wykonanie aplikacji, nie na połączenie lub na ciąg) jest w porządku.

Należy zauważyć, że w przeciwieństwie do firmy Microsoft, rozważania takie jak "robienie tego w ten sposób spowoduje, że wszystko inne będzie wolniejsze i spowoduje koszty, które sprawiają, że ta metoda jest głupia!" można zignorować, więc możliwe jest, że nawet zakładając, że Microsoft jest doskonały, można go pokonać, robiąc coś "głupiego".

To czysto ćwiczenie w mojej własnej ciekawości i nie będzie używane w prawdziwym kodzie.

Przykłady pomysłów Myślałem o:

  • Korzystanie z wielu rdzeni (obliczenia num2 i num samodzielnie)
  • Korzystanie GPU
+3

Wiele rdzeni lub procesorów graficznych może zwiększać prędkość tylko z ciągami * HUGE *. – Guillaume

+0

Wypróbowałeś któryś ze swoich pomysłów (numer 1 nie byłby zbyt trudny do zakodowania)? Jeśli tak, opublikuj to, co znalazłeś ... –

+0

Jakiś konkretny powód, aby go potrzebować, aby zwrócić to samo? Algorytm "GetHashCode" jest zdefiniowany przez implementację dla wszystkich typów zgodnie z ECMA-335, a w celu ponownego zastosowania 'GetHashCode' dla łańcucha znaków, używasz już innej implementacji CLR. –

Odpowiedz

1

Nici i GPU z pewnością wprowadzą obciążenie większe niż możliwe zwiększenie wydajności. Podejście, które można uzasadnić, to używanie zestawów instrukcji SIMD, takich jak SSE. Jednak wymagałoby to przetestowania, czy ten niepełny zestaw instrukcji jest dostępny, co może kosztować. Przyniesie również impuls tylko w przypadku długich łańcuchów.

Jeśli chcesz go wypróbować, rozważ testowanie Mono support for SIMD przed nurkowaniem w C lub montaż. Przeczytaj here o możliwościach rozwoju i lukach.

0

Można parallelize to jednak problem, który będzie napotkać to, że wątki, CUDA, itp. mają związane z nimi koszty ogólne. Nawet jeśli korzystasz z puli wątków, jeśli twoje struny nie są zbyt duże, powiedzmy, że typowy ciąg ma 128-256 znaków (prawdopodobnie mniej niż to), prawdopodobnie nadal będziesz kończył wykonywanie każdego połączenia z tą funkcją dłużej niż pierwotnie .

Teraz, jeśli masz do czynienia z bardzo dużymi łańcuchami, to tak, poprawiłoby to twój czas. Prosty algorytm jest "żenująco równoległy".

0

Myślę, że wszystkie sugerowane przez ciebie podejścia są bardzo nieefektywne w porównaniu z obecną implementacją.

Korzystanie GPU: Dane ciąg musi być przeniesione na GPU i wynik z powrotem, co zajmuje dużo czasu. Procesory graficzne są bardzo szybkie, ale tylko przy porównywaniu obliczeń zmiennoprzecinkowych, które nie są tutaj używane. Wszystkie operacje są na liczbach całkowitych, dla których moc procesora x86 jest przyzwoita.

pomocą innych procesora Rdzeń: będzie to wymagało utworzenia oddzielnej nić blokowania pamięci i synchronizacji gwint żądającą kod skrótu. Nałagany narzut po prostu przewyższa korzyści płynące z przetwarzania równoległego.

Jeśli chcesz obliczyć wartości Hash tysięcy ciągów za jednym zamachem, rzeczy mogą wyglądać nieco inaczej, ale nie mogę sobie wyobrazić scenariusza, w którym uzasadniałoby to wdrożenie szybszego GetHashCode().

+2

Jeśli chcesz obliczyć wartości skrótu od tysięcy ciągów, nie musisz ponownie implementować GetHashCode, po prostu wywołaj domyślny kod GetHashCode na różnych wątkach dla każdego ciągu. – Guillaume

+0

To prawda, ale nawet gdybyś miał tysiąc wątków, nadal chciałbyś, aby podstawowy kod dla skrótu działał tak szybko, jak można by uruchomić jego sekwencyjną wersję. –

0

Biorąc pod uwagę, że ciągi znaków są niezmienne, pierwszą rzeczą, którą bym rozważył, jest buforowanie wyniku zwracanego.

+0

Wymaga pamięci i jest przydatna tylko wtedy, gdy GetHashCode jest wywoływany wiele razy w tej samej instancji. – Guillaume

+0

Pytanie dotyczyło prędkości, a nie użycia pamięci;) –

+0

Kiedy mówimy o prymitywnych typach, prędkość i pamięć są ze sobą powiązane ... Ale masz rację, pytanie dotyczyło tylko prędkości określonego algorytmu. W każdym razie nadal pomaga tylko wtedy, gdy GetHashCode jest wywoływany wiele razy w tej samej instancji. – Guillaume

2

Jednym ze sposobów przyspieszenia działania jest uwzględnienie specjalnych przypadków. Funkcja z wejściami o zmiennej wielkości ma specjalne przypadki na podstawie rozmiaru.

Idąc równolegle ma sens tylko wtedy, gdy koszt będzie równoległy jest mniejszy niż zysk, a do tego rodzaju obliczeń jest prawdopodobne że ciąg musiałby być dość duża, aby przezwyciężyć koszt z rozwidlone gwint równoległy. Ale wdrożenie tego nie jest trudne; w zasadzie potrzebny jest do tego test. Długość przekraczająca empirycznie ustalony próg , a następnie rozwidlenie wielu wątków w celu obliczenia skrótów na podciągach, z ostatnim krokiem komponującym subhashy z końcowym hashem w . Implementacja pozostawiona dla czytelnika.

Nowoczesne procesory mają również instrukcje SIMD, które mogą przetwarzać od do 32 (lub 64) bajtów w pojedynczej instrukcji. To pozwoliłoby ci przetworzyć ciąg w 32 (16-bitowych znakach) w jednej-dwóch instrukcjach SIMD na porcję; a następnie złożyć 64-bajtowy wynik na jeden hashcode na końcu. Prawdopodobnie będzie to wyjątkowo szybkie dla ciągów o dowolnej rozsądnej wielkości. Wdrożenie tego z C# jest trudniejsze, ponieważ nie oczekuje się, że maszyna wirtualna zapewni łatwy (lub przenośny) dostęp do instrukcji SIMD, której potrzebujesz. Implementacja pozostała również dla czytelnika. EDYCJA: Kolejna odpowiedź sugeruje, że system Mono zapewnia dostęp do instrukcji SIMD z .

Powiedziawszy to, pokazana realizacja jest dość głupia. Kluczową obserwacją jest to, że pętla sprawdza limit dwa razy przy każdej iteracji. Można rozwiązać ten problem, sprawdzając z wyprzedzeniem przypadki stanu końcowego, i wykonując pętlę, która wykonuje poprawną liczbę iteracji. Można zrobić lepiej, używając Duffs device , aby przejść do rozwiniętej pętli iteracji N. To pozbywa się nadmiaru sprawdzania limitu pętli dla iteracji N-1. Ta modyfikacja byłaby bardzo łatwa i na pewno warta wysiłku wdrożenia.

EDYCJA: Można również połączyć pomysł SIMD i pomysł rozwijania pętli, aby umożliwić przetwarzanie wielu fragmentów 8/16 znaków w kilku instrukcjach SIMD.

Dla języków, które nie mogą przeskoczyć do pętli, można wykonać ekwiwalent urządzenia Duff, po prostu odrywając początkowe przypadki. Strzał w jak przekodować oryginalnego kodu z wykorzystaniem pętli obierania podejście jest następujące:

public override unsafe int GetHashCode() 
    { 
     fixed (char* str = ((char*) this)) 
     { 
      const int N=3; // a power of two controlling number of loop iterations 
      char* chPtr = str; 
      int num = 0x15051505; 
      int num2 = num; 
      int* numPtr = (int*) chPtr; 
      count = this.length; 
      unrolled_iterations = count >> (N+1); // could be 0 and that's OK 
      for (int i = unrolled_iterations; i > 0; i--) 
      { 
       // repeat 2**N times 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[2]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[3]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[4]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[5]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[6]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[7]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[8]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[9]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[10]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[11]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[12]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[13]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[14]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[15]; } 
       numPtr += 16; 
      } 
      if (count & ((1<<N)-1)) 
      { 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[2]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[3]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[4]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[5]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[6]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[7]; } 
       numPtr += 8; 
      } 
      if (count & ((1<<(N-1))-1)) 
      { 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; } 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[2]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[3]; } 
       numPtr += 4; 
      } 
      if (count & ((1<<(N-2)-1)) 
      { 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
       num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; } 
       numPtr += 2; 
      } 
      // repeat N times and finally: 
      if { count & (1) } 
      { 
       { num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
       // numPtr += 1; 
      } 

      return (num + (num2 * 0x5d588b65)); 
     } 
    } 

Nie skompilowany lub przetestowane ten kod, ale pomysł ma rację. To zależy od kompilatora wykonującego rozsądne stałe składanie i arytmetyki adresu.

Próbowałem to zakodować, aby zachować dokładną wartość mieszania oryginału, , ale IMHO, które tak naprawdę nie jest wymagane. Byłoby jeszcze prostsze i odrobinę szybsze, gdyby nie użyto num/num2 stunt, ale po prostu zaktualizowano num dla każdej postaci.


wersji poprawiono (Brian) jako funkcja statycznego:

public static unsafe int GetHashCodeIra(string x) 
    { 
     fixed (char* str = x.ToCharArray()) 
     { 
      const int N = 2; // a power of two controlling number of loop iterations 
      char* chPtr = str; 
      int num = 0x15051505; 
      int num2 = num; 
      int* numPtr = (int*)chPtr; 
      int count = (x.Length+1)/2; 
      int unrolled_iterations = count >> (N+1); // could be 0 and that's OK 
      for (int i = unrolled_iterations; i > 0; i--) 
      { // repeat 2**N times 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
        num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; 
       } 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[2]; 
        num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[3]; 
       } 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[4]; 
        num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[5]; 
       } 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[6]; 
        num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[7]; 
       } 
       numPtr += 8; 
      } 
      if (0 != (count & ((1 << N)))) 
      { 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
        num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; 
       } 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[2]; 
        num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[3]; 
       } 
       numPtr += 4; 
      } 
      if (0 != (count & ((1 << (N - 1))))) 
      { 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
        num2 = (((num2 << 5) + num2) + (num2 >> 0x1b))^numPtr[1]; 
       } 
       numPtr += 2; 
      } 
      // repeat N times and finally: 
      if (1 == (count & 1)) 
      { 
       { 
        num = (((num << 5) + num) + (num >> 0x1b))^numPtr[0]; 
        // numPtr += 1; 
       } 
      } 

      return (num + (num2 * 0x5d588b65)); 
     } 
    } 
+0

(po ustaleniu), przetestowałem to. Nie miało to znaczenia, ani w trybie debugowania, ani w trybie kompilacji. Pomogło to trochę w naprawdę długich łańcuchach (ponad 20 znaków) i zaszkodziło trochę na naprawdę krótkich łańcuchach (mniej niż 5 znaków). Jednak nie ma dużej różnicy. – Brian

+0

Cieszę się, że udało się to naprawić; Nie myślałem, że to było złe: -} Można spróbować cofnąć kroki obierania pętli: najpierw wykonaj krok jednostki i sprawdź, czy łańcuch jest krótki; następnie wykonaj dwa kroki: , następnie sprawdź, czy nie ma wyjścia, itp. Dzięki temu krótkie łańcuchy będą działały szybciej, praktycznie bez wpływu na dłuższe. Uprość również kod, aby uniknąć dychotomii num/num2; obliczenia mogą w tym przypadku przebiegać całkowicie w rejestrach. –

+0

... co właściwie mierzysz? Czas na przeprowadzenie wywiadu, w którym uczestniczyło mieszanie, lub czas na samo spakowanie? –

0

Każdy krok do wyliczenia opiera się na wyniku z poprzedniego etapu. Jeśli powtórzy się pętla pętli, otrzymasz inny wynik: (wartość num z poprzedniej iteracji służy jako wejście do następnej iteracji).

Z tego powodu dowolne podejście (wielowątkowość, masowo równoległe wykonywanie na GPU), które uruchamia kroki równolegle, będzie generalnie pochylić wynik.

Byłbym również zaskoczony, gdyby wcześniej omawiana pętla rozwijania nie była już wykonywana wewnętrznie przez kompilator w takim zakresie, w jakim faktycznie robi on różnicę w czasie wykonywania (kompilatory wydają się być mądrzejsze od przeciętnego programisty w dzisiejszych czasach, i rozwijanie w pętli było przez bardzo długi czas jako technika optymalizacji kompilatora).