Python (+ numba) é mais rápido que C - realmente ?! Parte 2. Prática

Esta é a segunda parte do artigo sobre numba. A primeira foi uma introdução histórica e um breve manual de instruções numba. Apresento aqui um código de tarefa ligeiramente modificado do artigo Haskell “ Faster than C ++; mais lento que o PHP ”(compara o desempenho de implementações de um algoritmo em diferentes linguagens / compiladores) com benchmarks, gráficos e explicações mais detalhados. Farei imediatamente a reserva de que vi o artigo Oh, esse lento C / C ++ e, provavelmente, se você fizer essas alterações no código C, a imagem mudará um pouco, mas mesmo nesse caso, o python é capaz de exceder a velocidade do C, mesmo nesta versão é em si notável.



Substituiu a lista Python por uma matriz numpy (e, consequentemente, v0[:] por v0.copy() , porque em numpy a[:] retorna a view vez de copiar).


Para entender a natureza do comportamento de desempenho, fiz uma "varredura" pelo número de elementos na matriz.


No código Python, substituí time.monotonic por time.perf_counter , pois é mais preciso (1us versus 1ms para monotônico).


Como o numba usa a compilação jit, essa mesma compilação deve ocorrer algum dia. Por padrão, ocorre na primeira vez em que a função é chamada e afeta inevitavelmente os resultados dos benchmarks (embora se você dedicar um tempo de três lançamentos, talvez não perceba isso), e também é sentida no uso prático. Existem várias maneiras de lidar com esse fenômeno:


1) resultados da compilação de cache no disco:


 @njit(cache=True) def lev_dist(s1: AnyStr, s2: AnyStr) -> int: 

a compilação ocorrerá na primeira chamada do programa e nas chamadas subseqüentes serão extraídas do disco.


2) indicar a assinatura


A compilação ocorrerá no momento em que o python analisa a definição da função e o primeiro início já será rápido.


A string original é transmitida (mais precisamente, bytes), mas o suporte a strings foi adicionado recentemente, portanto a assinatura é bastante monstruosa (veja abaixo). As assinaturas geralmente são escritas de maneira mais simples:


 @njit(nb.int64(nb.uint8[:], nb.uint8[:])) def lev_dist(s1, s2): 

mas você precisa converter bytes em uma matriz numpy com antecedência:


 s1_py = [int(x) for x in b"a" * 15000] s1 = np.array(s1_py, dtype=np.uint8) 

ou


 s1 = np.full(15000, ord('a'), dtype=np.uint8) 

E você pode deixar os bytes como estão e especificar a assinatura neste formulário:


 @njit(nb.int64(nb.bytes(nb.uint8, nb.1d, nb.C), nb.bytes(nb.uint8, nb.1d, nb.C))) def lev_dist(s1: AnyStr, s2: AnyStr) -> int: 

A velocidade de execução dos bytes e da matriz numpy do uint8 (neste caso) é a mesma.


3) cache de pré-aquecimento


  s1 = b"a" * 15 # 15  15000 s2 = s1 s3 = b"b" * 15 exec_time = -clock() print(lev_dist(s1, s2)) print(lev_dist(s1, s3)) exec_time += clock() print(f"Finished in {exec_time:.3f}s", file=sys.stderr) 

Em seguida, a compilação ocorrerá na primeira chamada e a segunda já será rápida.


Código Python
 #!/usr/bin/env python3 import sys import time from numba import njit import numpy as np, numba as nb from time import perf_counter as clock @njit(nb.int64(nb.uint8[::1], nb.uint8[::1])) def lev_dist(s1, s2): m = len(s1) n = len(s2) # Edge cases. if m == 0: return n elif n == 0: return m v0 = np.arange(n + 1) v1 = v0.copy() for i, c1 in enumerate(s1): v1[0] = i + 1 for j, c2 in enumerate(s2): subst_cost = v0[j] if c1 == c2 else (v0[j] + 1) del_cost = v0[j + 1] + 1 ins_cost = v1[j] + 1 min_cost = min(subst_cost, del_cost, ins_cost) v1[j + 1] = min_cost v0, v1 = v1, v0 return v0[n] if __name__ == "__main__": fout = open('py.txt', 'w') for n in 1000, 2000, 5000, 10000, 15000, 20000, 25000: s1 = np.full(n, ord('a'), dtype=np.uint8) s2 = s1 s3 = np.full(n, ord('b'), dtype=np.uint8) exec_time = -clock() print(lev_dist(s1, s2)) print(lev_dist(s1, s3)) exec_time += clock() print(f'{n} {exec_time:.6f}', file=fout) 

Código C (clang -O3 -march = nativo)
 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <time.h> static long lev_dist (const char *s1, unsigned long m, const char *s2, unsigned long n) { // unsigned long m, n; unsigned long i, j; long *v0, *v1; long ret, *temp; /* Edge cases. */ if (m == 0) { return n; } else if (n == 0) { return m; } v0 = malloc (sizeof (long) * (n + 1)); v1 = malloc (sizeof (long) * (n + 1)); if (v0 == NULL || v1 == NULL) { fprintf (stderr, "failed to allocate memory\n"); exit (-1); } for (i = 0; i <= n; ++i) { v0[i] = i; } memcpy (v1, v0, sizeof(long) * (n + 1)); for (i = 0; i < m; ++i) { v1[0] = i + 1; for (j = 0; j < n; ++j) { const long subst_cost = (s1[i] == s2[j]) ? v0[j] : (v0[j] + 1); const long del_cost = v0[j + 1] + 1; const long ins_cost = v1[j] + 1; #if !defined(__GNUC__) || defined(__llvm__) if (subst_cost < del_cost) { v1[j + 1] = subst_cost; } else { v1[j + 1] = del_cost; } #else v1[j + 1] = (subst_cost < del_cost) ? subst_cost : del_cost; #endif if (ins_cost < v1[j + 1]) { v1[j + 1] = ins_cost; } } temp = v0; v0 = v1; v1 = temp; } ret = v0[n]; free (v0); free (v1); return ret; } int main () { char s1[25001], s2[25001], s3[25001]; int lengths[] = {1000, 2000, 5000, 10000, 15000, 20000, 25000}; FILE *fout; fopen_s(&fout, "c.txt", "w"); for(int j = 0; j < sizeof(lengths)/sizeof(lengths[0]); j++){ int len = lengths[j]; int i; clock_t start_time, exec_time; for (i = 0; i < len; ++i) { s1[i] = 'a'; s2[i] = 'a'; s3[i] = 'b'; } s1[len] = s2[len] = s3[len] = '\0'; start_time = clock (); printf ("%ld\n", lev_dist (s1, len, s2, len)); printf ("%ld\n", lev_dist (s1, len, s3, len)); exec_time = clock () - start_time; fprintf(fout, "%d %.6f\n", len, ((double) exec_time) / CLOCKS_PER_SEC); fprintf (stderr, "Finished in %.3fs\n", ((double) exec_time) / CLOCKS_PER_SEC); } return 0; } 

A comparação foi realizada no windows (windows 10 x64, python 3.7.3, numba 0.45.1, clang 9.0.0, intel m5-6y54 skylake): e no linux (debian 4.9.30, python 3.7.4, numba 0.45.1, 9.0.0).


X é o tamanho da matriz, y é o tempo em segundos.


Escala linear do Windows:



Escala logarítmica do Windows:



Escala linear do Linux:



Escala logarítmica do Linux



Nesse problema, foi obtido um aumento na velocidade comparado ao clang no nível de vários por cento, o que geralmente é maior que o erro estatístico.


Eu fiz essa comparação repetidamente em diferentes tarefas e, como regra, se o numba pode acelerar algo, ele o acelera a uma velocidade dentro da margem de erro coincidente com a velocidade C (sem o uso de inserções de montagem).


Repito que, se você fizer alterações no código em C de Oh, essa situação lenta do C / C ++ poderá mudar.


Ficarei feliz em ouvir perguntas e sugestões nos comentários.


PS Ao especificar a assinatura de matrizes, é melhor definir explicitamente a maneira de alternar linhas / colunas:
para que a numba não pense em 'C' (si) isso ou 'A' (reconhecimento automático de si / fortran) - por algum motivo, isso afeta o desempenho mesmo para matrizes unidimensionais, por isso existe uma sintaxe tão original: uint8[:,:] this ' A '(detecção automática), nb.uint8[:, ::1] é' C '(si), np.uint8[::1, :] é' F '(fortran).


 @njit(nb.int64(nb.uint8[::1], nb.uint8[::1])) def lev_dist(s1, s2): 

Source: https://habr.com/ru/post/pt484142/


All Articles