Repository: MannLabs/alphapept (language: python)

Issue:
Gpu rev sw
This is to test if the GPU revs are fully functional.

NOTE: A good notebook to compare timings from python vs. numba vs. numba-multithread vs. cuda is still missing and should be implemented before final merge


Discussion / hints:

Fix patch:
diff --git a/alphapept/_nbdev.py b/alphapept/_nbdev.py
index 1c898e8c..f1af8fbf 100644
--- a/alphapept/_nbdev.py
+++ b/alphapept/_nbdev.py
@@ -86,7 +86,6 @@
          "find_path_start": "04_feature_finding.ipynb",
          "find_path_length": "04_feature_finding.ipynb",
          "fill_path_matrix": "04_feature_finding.ipynb",
-         "convert_to_coord_path": "04_feature_finding.ipynb",
          "find_centroid_connections": "04_feature_finding.ipynb",
          "get_hills": "04_feature_finding.ipynb",
          "connect_centroids": "04_feature_finding.ipynb",
@@ -255,13 +254,30 @@
          "cli_gui": "11_interface.ipynb",
          "CONTEXT_SETTINGS": "11_interface.ipynb",
          "CLICK_SETTINGS_OPTION": "11_interface.ipynb",
+         "dummy_decorator": "12_speed.ipynb",
+         "set_speed_mode": "12_speed.ipynb",
          "grid_1d": "12_speed.ipynb",
          "grid_2d": "12_speed.ipynb",
          "set_cuda_grid": "12_speed.ipynb",
+         "numba_threaded": "12_speed.ipynb",
          "parallel_compiled_func": "12_speed.ipynb",
          "set_max_process": "12_speed.ipynb",
          "AlphaPool": "12_speed.ipynb",
+         "cupy": "12_speed.ipynb",
+         "cuda": "12_speed.ipynb",
          "numba": "12_speed.ipynb",
+         "jit_fun": "12_speed.ipynb",
+         "jit_fun_gpu": "12_speed.ipynb",
+         "speed_mode": "12_speed.ipynb",
+         "COMPILATION_MODE_OPTIONS": "13_performance.ipynb",
+         "is_valid_compilation_mode": "13_performance.ipynb",
+         "set_worker_count": "13_performance.ipynb",
+         "MAX_WORKER_COUNT": "13_performance.ipynb",
+         "set_compilation_mode": "13_performance.ipynb",
+         "compile_function": "13_performance.ipynb",
+         "__copy_func": "13_performance.ipynb",
+         "DYNAMIC_COMPILATION_ENABLED": "13_performance.ipynb",
+         "performance_function": "13_performance.ipynb"}
          "cuda": "12_speed.ipynb",
          "mq_ouput_files": "13_export.ipynb",
          "mod_translation": "13_export.ipynb",
@@ -282,6 +298,7 @@
            "constants.py",
            "interface.py",
            "speed.py",
+           "performance.py"]
            "export.py"]
 
 doc_url = "https://mannlabs.github.io/alphapept/"
diff --git a/alphapept/feature_finding.py b/alphapept/feature_finding.py
index e9147f22..ff7ca5af 100644
--- a/alphapept/feature_finding.py
+++ b/alphapept/feature_finding.py
@@ -1,32 +1,27 @@
 # AUTOGENERATED! DO NOT EDIT! File to edit: nbs/04_feature_finding.ipynb (unless otherwise specified).
 
 __all__ = ['connect_centroids_unidirection', 'convert_connections_to_array', 'eliminate_overarching_vertex',
-           'path_finder', 'find_path_start', 'find_path_length', 'fill_path_matrix', 'convert_to_coord_path',
-           'find_centroid_connections', 'get_hills', 'connect_centroids', 'extract_hills', 'fast_minima', 'split',
-           'split_hills', 'check_large_hills', 'filter_hills', 'hill_stats', 'remove_duplicates', 'get_hill_data',
-           'check_isotope_pattern', 'DELTA_M', 'DELTA_S', 'maximum_offset', 'correlate', 'extract_edge',
-           'edge_correlation', 'get_pre_isotope_patterns', 'check_isotope_pattern_directed', 'grow', 'grow_trail',
-           'get_trails', 'plot_pattern', 'get_local_minima', 'is_local_minima', 'get_minpos', 'truncate',
-           'check_averagine', 'pattern_to_mz', 'cosine_averagine', 'int_list_to_array', 'mz_to_mass', 'M_PROTON',
-           'isolate_isotope_pattern', 'get_isotope_patterns', 'report_', 'feature_finder_report',
-           'plot_isotope_pattern', 'extract_bruker', 'convert_bruker', 'map_bruker', 'find_features', 'replace_infs',
-           'map_ms2']
+           'path_finder', 'find_path_start', 'find_path_length', 'fill_path_matrix', 'find_centroid_connections',
+           'get_hills', 'connect_centroids', 'extract_hills', 'fast_minima', 'split', 'split_hills',
+           'check_large_hills', 'filter_hills', 'hill_stats', 'get_hill_data', 'check_isotope_pattern', 'DELTA_M',
+           'DELTA_S', 'maximum_offset', 'correlate', 'extract_edge', 'edge_correlation', 'get_pre_isotope_patterns',
+           'check_isotope_pattern_directed', 'grow', 'grow_trail', 'get_trails', 'plot_pattern', 'get_minpos',
+           'get_local_minima', 'is_local_minima', 'truncate', 'check_averagine', 'pattern_to_mz', 'cosine_averagine',
+           'int_list_to_array', 'mz_to_mass', 'get_minpos', 'get_local_minima', 'is_local_minima', 'truncate',
+           'M_PROTON', 'isolate_isotope_pattern', 'get_isotope_patterns', 'report_', 'feature_finder_report',
+           'plot_isotope_pattern', 'extract_bruker', 'convert_bruker', 'map_bruker', 'find_features', 'map_ms2']
+
 
 # Cell
-import alphapept.speed
 import numpy as np
-from .speed import parallel_compiled_func, cupy, jit_fun
 
-@parallel_compiled_func
-def connect_centroids_unidirection(idx, row_borders, connections, scores, centroids, max_gap, centroid_tol):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
+import alphapept.performance
 
+@alphapept.performance.performance_function
+def connect_centroids_unidirection(x, row_borders, connections, scores, centroids, max_gap, ppm_tol):
     for gap in range(max_gap + 1):
         y = x + gap + 1
-        if y > row_borders.shape[0]:
+        if y >= row_borders.shape[0]:
             return
 
         start_index_f = 0
@@ -36,6 +31,7 @@ def connect_centroids_unidirection(idx, row_borders, connections, scores, centro
         centroids_1 = centroids[start_index_f: row_borders[x]]
 
         start_index_b = row_borders[y - 1]
+
         centroids_2 = centroids[start_index_b: row_borders[y]]
 
         i = 0
@@ -58,13 +54,8 @@ def connect_centroids_unidirection(idx, row_borders, connections, scores, centro
             else:
                 i += 1
 
-@parallel_compiled_func
-def convert_connections_to_array(idx, from_r, from_c, to_r, to_c, row_borders, out_from_idx, out_to_idx):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
-
+@alphapept.performance.performance_function
+def convert_connections_to_array(x, from_r, from_c, to_r, to_c, row_borders, out_from_idx, out_to_idx):
     row = from_r[x]
     col = from_c[x]
     start_index_f = 0
@@ -79,47 +70,29 @@ def convert_connections_to_array(idx, from_r, from_c, to_r, to_c, row_borders, o
         start_index_f = row_borders[row - 1]
     out_to_idx[x] = start_index_f + col
 
-@parallel_compiled_func
-def eliminate_overarching_vertex(idx, from_idx, to_idx):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
+@alphapept.performance.performance_function
+def eliminate_overarching_vertex(x, from_idx, to_idx):
     if x == 0:
         return
 
     if from_idx[x - 1] == from_idx[x]:
         to_idx[x] = -1
 
-@parallel_compiled_func
-def path_finder(idx, from_idx, to_idx, forward, backward):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
+@alphapept.performance.performance_function
+def path_finder(x, from_idx, to_idx, forward, backward):
     fr = from_idx[x]
     to =  to_idx[x]
 
     forward[fr] = to
     backward[to] = fr
 
-@parallel_compiled_func
-def find_path_start(idx, forward, backward, path_starts):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
-
+@alphapept.performance.performance_function
+def find_path_start(x, forward, backward, path_starts):
     if forward[x] > -1 and backward[x] == -1:
         path_starts[x] = 0
 
-@parallel_compiled_func
-def find_path_length(idx, path_starts, forward, path_cnt):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
-
+@alphapept.performance.performance_function
+def find_path_length(x, path_starts, forward, path_cnt):
     ctr = 1
     idx = path_starts[x]
     while forward[idx] > -1:
@@ -127,13 +100,8 @@ def find_path_length(idx, path_starts, forward, path_cnt):
         idx = forward[idx]
     path_cnt[x] = ctr
 
-@parallel_compiled_func
-def fill_path_matrix(idx, path_start, forwards, out_hill_data, out_hill_ptr):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
-
+@alphapept.performance.performance_function
+def fill_path_matrix(x, path_start, forwards, out_hill_data, out_hill_ptr):
     path_position = 0
     idx = path_start[x]
     while idx > -1:
@@ -141,32 +109,33 @@ def fill_path_matrix(idx, path_start, forwards, out_hill_data, out_hill_ptr):
         idx = forwards[idx]
         path_position += 1
 
-@parallel_compiled_func
-def convert_to_coord_path(idx, out_rows, out_cols, connected_comps, d_width):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
+# @alphapept.performance.performance_function
+# def convert_to_coord_path(x, out_rows, out_cols, connected_comps, d_width):
+#     idx = connected_comps[x]
+#     row = idx // d_width
+#     col = idx - row * d_width
+#     out_rows[x] = row
+#     out_cols[x] = col
 
-    idx = connected_comps[x]
-    row = idx // d_width
-    col = idx - row * d_width
+# Cell
 
-    out_rows[x] = row
-    out_cols[x] = col
 
-# Cell
+def find_centroid_connections(rowwise_peaks, row_borders, centroids, max_gap, ppm_tol):
+    if alphapept.performance.COMPILATION_MODE == "cuda":
+        import cupy
+        cupy = cupy
+    else:
+        import numpy
+        cupy = numpy
 
-def find_centroid_connections(rowwise_peaks, row_borders, centroids, max_gap=2, centroid_tol=8):
     max_centroids = int(cupy.max(rowwise_peaks))
     spectra_cnt = len(row_borders) - 1
 
     connections = cupy.full((spectra_cnt, max_centroids, max_gap + 1), -1, dtype=np.int32)
     score = cupy.full((spectra_cnt, max_centroids, max_gap + 1), np.inf)
 
-
-
-    connect_centroids_unidirection(row_borders,
+    connect_centroids_unidirection(range(len(row_borders)),
+                                    row_borders,
                                    connections,
                                    score,
                                    centroids,
@@ -178,7 +147,6 @@ def find_centroid_connections(rowwise_peaks, row_borders, centroids, max_gap=2,
     score_median = cupy.median(score)
     score_std = cupy.std(score)
 
-
     del score, max_centroids, spectra_cnt
 
     c_shape = connections.shape
@@ -187,27 +155,35 @@ def find_centroid_connections(rowwise_peaks, row_borders, centroids, max_gap=2,
     to_c = connections[from_r, from_c, from_g] - to_r * c_shape[1]
 
     del connections, from_g
+
     return from_r, from_c, to_r, to_c, score_median, score_std
 
 # Cell
 
-def get_hills(centroids, from_idx, to_idx, hill_length_min=3):
+def get_hills(centroids, from_idx, to_idx, min_hill_length=3):
+    if alphapept.performance.COMPILATION_MODE == "cuda":
+        import cupy
+        cupy = cupy
+    else:
+        import numpy
+        cupy = numpy
+
     forward = cupy.full(centroids.shape[0], -1)
     backward = cupy.full(centroids.shape[0], -1)
     path_starts = cupy.full(centroids.shape[0], -1)
 
-    path_finder(from_idx, to_idx, forward, backward)
-    find_path_start(forward, backward, path_starts)
+    path_finder(range(len(from_idx)), from_idx, to_idx, forward, backward)
+    find_path_start(range(len(forward)), forward, backward, path_starts)
 
     # path_starts will now container the first index of all connected centroids
     path_starts = cupy.where(path_starts == 0)[0]
 
     path_node_cnt = cupy.full(path_starts.shape[0], -1)
-    find_path_length(path_starts, forward, path_node_cnt)
+    find_path_length(range(len(path_starts)), path_starts, forward, path_node_cnt)
 
     relavant_path_node = cupy.where(path_node_cnt >= hill_length_min)[0]
     path_starts = cupy.take(path_starts, relavant_path_node)
-    path_node_cnt = cupy.take( path_node_cnt, relavant_path_node)
+    path_node_cnt = cupy.take(path_node_cnt, relavant_path_node)
     del relavant_path_node
 
     # Generate the hill matix indice ptr data
@@ -217,24 +193,32 @@ def get_hills(centroids, from_idx, to_idx, hill_length_min=3):
     hill_ptrs[1:] = path_node_cnt.cumsum()
     hill_data = cupy.empty((int(hill_ptrs[-1])), np.int32)
 
-    fill_path_matrix(path_starts, forward, hill_data, hill_ptrs)
+    fill_path_matrix(range(len(path_starts)), path_starts, forward, hill_data, hill_ptrs)
 
     del from_idx, to_idx, path_starts, forward, backward
     return hill_ptrs, hill_data, path_node_cnt
 
 # Cell
-def connect_centroids(rowwise_peaks, row_borders, centroids, max_gap=2, centroid_tol=8):
+def connect_centroids(rowwise_peaks, row_borders, centroids, max_gap, ppm_tol):
+    if alphapept.performance.COMPILATION_MODE == "cuda":
+        import cupy
+        cupy = cupy
+    else:
+        import numpy
+        cupy = numpy
 
     from_r, from_c, to_r, to_c, score_median, score_std = find_centroid_connections(rowwise_peaks,
                                                            row_borders,
                                                            centroids,
-                                                           max_gap=max_gap,
-                                                           centroid_tol=centroid_tol)
+                                                           max_gap,
+                                                           ppm_tol)
+
 
     from_idx = cupy.zeros(len(from_r), np.int32)
     to_idx = cupy.zeros(len(from_r), np.int32)
 
-    convert_connections_to_array(from_r,
+    convert_connections_to_array(range(len(from_r)),
+                                    from_r,
                                  from_c,
                                  to_r,
                                  to_c,
@@ -242,7 +226,7 @@ def connect_centroids(rowwise_peaks, row_borders, centroids, max_gap=2, centroid
                                  from_idx,
                                  to_idx)
 
-    eliminate_overarching_vertex(from_idx, to_idx)
+    eliminate_overarching_vertex(range(len(from_idx)), from_idx, to_idx)
 
     relavent_idx = cupy.where(to_idx >= 0)
     from_idx = cupy.take(from_idx, relavent_idx)[0]
@@ -253,7 +237,14 @@ def connect_centroids(rowwise_peaks, row_borders, centroids, max_gap=2, centroid
 
 
 # Cell
-def extract_hills(query_data, max_gap = 2, centroid_tol = 8):
+def extract_hills(query_data, max_gap, ppm_tol):
+
+    if alphapept.performance.COMPILATION_MODE == "cuda":
+        import cupy
+        cupy = cupy
+    else:
+        import numpy
+        cupy = numpy
 
     indices = cupy.array(query_data['indices_ms1'])
     mass_data = cupy.array(query_data['mass_list_ms1'])
@@ -261,7 +252,8 @@ def extract_hills(query_data, max_gap = 2, centroid_tol = 8):
     rowwise_peaks = indices[1:] - indices[:-1]
     row_borders = indices[1:]
 
-    from_idx, to_idx, score_median, score_std = connect_centroids(rowwise_peaks, row_borders, mass_data, max_gap=max_gap, centroid_tol=centroid_tol)
+    from_idx, to_idx, score_median, score_std = connect_centroids(rowwise_peaks, row_borders, mass_data, max_gap, ppm_tol)
+
     hill_ptrs, hill_data, path_node_cnt = get_hills(mass_data, from_idx, to_idx)
 
     del mass_data
@@ -275,13 +267,10 @@ def extract_hills(query_data, max_gap = 2, centroid_tol = 8):
         score_median = score_median.get()
         score_std = score_std.get()
 
-
     return hill_ptrs, hill_data, path_node_cnt, score_median, score_std
 
-
-
 # Cell
-@jit_fun
+@alphapept.performance.compile_function(compilation_mode="numba")
 def fast_minima(y):
     minima = np.zeros(len(y))
 
@@ -300,14 +289,11 @@ def fast_minima(y):
 
     return minima
 
-@parallel_compiled_func(cpu_only = True)
-def split(idx, hill_ptrs_range, hill_ptrs, int_data, hill_data, splits, hill_split_level, window):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
+@alphapept.performance.performance_function(compilation_mode="numba-multithread")
+def split(k, hill_ptrs, int_data, hill_data, splits, split_level, window):
+# def split(idx, hill_ptrs_range, hill_ptrs, int_data, hill_data, splits, split_level, window):
 
-    k = hill_ptrs_range[x]
+#     k = hill_ptrs_range[idx]
 
     start = hill_ptrs[k]
     end = hill_ptrs[k + 1]
@@ -347,12 +333,32 @@ def split(idx, hill_ptrs_range, hill_ptrs, int_data, hill_data, splits, hill_spl
             splits[k] = start+min_
             break # Split only once per iteration
 
-def split_hills(hill_ptrs, hill_data, int_data, hill_split_level, window):
-    splits = np.zeros(len(hill_ptrs), dtype=cupy.int32)
+def split_hills(hill_ptrs, hill_data, int_data, split_level, window):
+
+    splits = np.zeros(len(int_data), dtype=np.int32)
     to_check = np.arange(len(hill_ptrs)-1)
 
     while len(to_check) > 0:
-        split(to_check, hill_ptrs, int_data, hill_data, splits, hill_split_level, window)
+#         print(to_check)
+#         print(len(hill_ptrs), len(to_check), len(splits))
+#         print(
+#             type(to_check),
+#             type(hill_ptrs),
+#             type(int_data),
+#             type(hill_data),
+#             type(splits),
+#             type(split_level),
+#             type(window)
+#         )
+#         print(
+#             to_check.dtype,
+#             hill_ptrs.dtype,
+#             int_data.dtype,
+#             hill_data.dtype,
+#             splits.dtype,
+#         )
+#         split(range(len(to_check)), to_check, hill_ptrs, int_data, hill_data, splits, split_level, window)
+        split(to_check, hill_ptrs, int_data, hill_data, splits, split_level, window)
         splitpoints = splits.nonzero()[0]
 
         to_check = np.zeros(len(hill_ptrs))
@@ -366,14 +372,10 @@ def split_hills(hill_ptrs, hill_data, int_data, hill_split_level, window):
     return hill_ptrs
 
 # Cell
-@parallel_compiled_func(cpu_only=True)
-def check_large_hills(idx, large_peaks, hill_ptrs, hill_data, int_data, to_remove, hill_check_large = 40, hill_peak_factor = 2, window=1):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
+@alphapept.performance.performance_function(compilation_mode="numba-multithread")
+def check_large_hills(idx, large_peaks, hill_ptrs, hill_data, int_data, to_remove, large_peak = 40, hill_peak_factor = 2, window=1):
 
-    k = large_peaks[x]
+    k = large_peaks[idx]
 
     start = hill_ptrs[k]
     end = hill_ptrs[k + 1]
@@ -397,7 +399,7 @@ def check_large_hills(idx, large_peaks, hill_ptrs, hill_data, int_data, to_remov
     max_ = np.max(int_)
 
     if (max_ / int_smooth_[0] > hill_peak_factor) & (max_ / int_smooth_[-1] > hill_peak_factor):
-        to_remove[x] = 0
+        to_remove[idx] = 0
 
 
 def filter_hills(hill_data, hill_ptrs, int_data, hill_check_large =40, window = 1):
@@ -405,7 +407,7 @@ def filter_hills(hill_data, hill_ptrs, int_data, hill_check_large =40, window =
     large_peaks = np.where(np.diff(hill_ptrs)>=hill_check_large)[0]
 
     to_remove = np.ones(len(large_peaks), dtype=np.int32)
-    check_large_hills(large_peaks, hill_ptrs, hill_data, int_data, to_remove, window)
+    check_large_hills(range(len(large_peaks)), large_peaks, hill_ptrs, hill_data, int_data, to_remove, window)
 
     idx_ = np.ones(len(hill_data), dtype = np.int32)
     keep = np.ones(len(hill_ptrs)-1, dtype = np.int32)
@@ -429,19 +431,13 @@ def filter_hills(hill_data, hill_ptrs, int_data, hill_check_large =40, window =
 
 # Cell
 
-@parallel_compiled_func(cpu_only=True) #Only CPU optimized at this point
+@alphapept.performance.performance_function(compilation_mode="numba-multithread")
 def hill_stats(idx, hill_range, hill_ptrs, hill_data, int_data, mass_data, rt_, rt_idx, stats, hill_nboot_max, hill_nboot):
-    if idx == -1:
-        k = alphapept.speed.cuda.grid(1)
-    else:
-        k = idx
-
-    x = hill_range[k]
 
     np.random.seed(42)
 
-    start = hill_ptrs[x]
-    end = hill_ptrs[x + 1]
+    start = hill_ptrs[idx]
+    end = hill_ptrs[idx + 1]
 
     idx_ = hill_data[start:end]
 
@@ -475,12 +471,12 @@ def hill_stats(idx, hill_range, hill_ptrs, hill_data, int_data, mass_data, rt_,
         delta += (average_mz - averages[i]) ** 2 #maybe easier?
     delta_m = np.sqrt(delta / (hill_nboot - 1))
 
-    stats[x,0] = average_mz
-    stats[x,1] = delta_m
-    stats[x,2] = int_sum
-    stats[x,3] = int_area
-    stats[x,4] = rt_min
-    stats[x,5] = rt_max
+    stats[idx,0] = average_mz
+    stats[idx,1] = delta_m
+    stats[idx,2] = int_sum
+    stats[idx,3] = int_area
+    stats[idx,4] = rt_min
+    stats[idx,5] = rt_max
 
 def remove_duplicates(stats, hill_data, hill_ptrs):
     dups = pd.DataFrame(stats).duplicated() #all duplicated hills
@@ -511,7 +507,7 @@ def get_hill_data(query_data, hill_ptrs, hill_data, hill_nboot_max = 300, hill_n
     int_data = np.array(query_data['int_list_ms1'])
 
     stats = np.zeros((len(hill_ptrs)-1, 6)) #mz, delta, rt_min, rt_max, sum_max
-    hill_stats(np.arange(len(hill_ptrs)-1), hill_ptrs, hill_data, int_data, mass_data, rt_, scan_idx, stats, hill_nboot_max, hill_nboot)
+    hill_stats(range(len(hill_ptrs)-1), np.arange(len(hill_ptrs)-1), hill_ptrs, hill_data, int_data, mass_data, rt_, scan_idx, stats, hill_nboot_max, hill_nboot)
 
     # Remove duplicate hills
     hill_data, hill_ptrs, stats = remove_duplicates(stats, hill_data, hill_ptrs)
@@ -526,14 +522,13 @@ def get_hill_data(query_data, hill_ptrs, hill_data, hill_nboot_max = 300, hill_n
 
 # Cell
 from .constants import mass_dict
-from numba import njit
 
 DELTA_M = mass_dict['delta_M']
 DELTA_S = mass_dict['delta_S']
 maximum_offset = DELTA_M + DELTA_S
 
-@njit
-def check_isotope_pattern(mass1, mass2, delta_mass1, delta_mass2, charge, iso_mass_range = 5):
+@alphapept.performance.compile_function(compilation_mode="numba")
+def check_isotope_pattern(mass1, mass2, delta_mass1, delta_mass2, charge, mass_range = 5):
     """
     Check if two masses could belong to the same isotope pattern
     """
@@ -549,7 +544,7 @@ def check_isotope_pattern(mass1, mass2, delta_mass1, delta_mass2, charge, iso_ma
 
 # Cell
 
-@njit
+@alphapept.performance.compile_function(compilation_mode="numba")
 def correlate(scans_, scans_2, int_, int_2):
 
     min_one, max_one = scans_[0], scans_[-1]
@@ -576,8 +571,9 @@ def correlate(scans_, scans_2, int_, int_2):
     return corr
 
 # Cell
-@njit
-def extract_edge(stats, idxs_upper, runner, max_index, maximum_offset,  iso_charge_min = 1, iso_charge_max = 6, iso_mass_range=5):
+
+@alphapept.performance.compile_function(compilation_mode="numba")
+def extract_edge(stats, idxs_upper, runner, max_index, maximum_offset,  min_charge = 1, max_charge = 6, mass_range=5):
     edges = []
 
     mass1 = stats[runner, 0]
@@ -594,14 +590,10 @@ def extract_edge(stats, idxs_upper, runner, max_index, maximum_offset,  iso_char
 
     return edges
 
-@parallel_compiled_func(cpu_only=True) #Only CPU optimized at this point
+@alphapept.performance.performance_function(compilation_mode="numba-multithread")
 def edge_correlation(idx, to_keep, sortindex_, pre_edges, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
 
-    edge = pre_edges[x,:]
+    edge = pre_edges[idx,:]
 
     y = sortindex_[edge[0]]
     start = hill_ptrs[y]
@@ -618,7 +610,7 @@ def edge_correlation(idx, to_keep, sortindex_, pre_edges, hill_ptrs, hill_data,
     scans_2 = scan_idx[idx_2]
 
     if correlate(scans_, scans_2, int_, int_2) > cc_cutoff:
-        to_keep[x] = 1
+        to_keep[idx] = 1
 
 # Cell
 import networkx as nx
@@ -632,7 +624,7 @@ def get_pre_isotope_patterns(stats, idxs_upper, sortindex_, hill_ptrs, hill_data
 
     to_keep = np.zeros(len(pre_edges), dtype='int')
     pre_edges = np.array(pre_edges)
-    edge_correlation(to_keep, sortindex_, pre_edges, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff)
+    edge_correlation(range(len(to_keep)), to_keep, sortindex_, pre_edges, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff)
     edges = pre_edges[to_keep.nonzero()]
 
     G2 = nx.Graph()
@@ -649,8 +641,9 @@ def get_pre_isotope_patterns(stats, idxs_upper, sortindex_, hill_ptrs, hill_data
 
 # Cell
 
-@njit
-def check_isotope_pattern_directed(mass1, mass2, delta_mass1, delta_mass2, charge, index, iso_mass_range):
+
+@alphapept.performance.compile_function(compilation_mode="numba")
+def check_isotope_pattern_directed(mass1, mass2, delta_mass1, delta_mass2, charge, index, mass_range):
     """
     Check if two masses could belong to the same isotope pattern
 
@@ -664,8 +657,8 @@ def check_isotope_pattern_directed(mass1, mass2, delta_mass1, delta_mass2, charg
     return left_side <= right_side
 
 
-@njit
-def grow(trail, seed, direction, relative_pos, index, stats, pattern, charge, iso_mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):
+@alphapept.performance.compile_function(compilation_mode="numba")
+def grow(trail, seed, direction, relative_pos, index, stats, pattern, charge, mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):
     """
     Grows isotope pattern based on a seed and direction
 
@@ -725,8 +718,8 @@ def grow(trail, seed, direction, relative_pos, index, stats, pattern, charge, is
 
     return trail
 
-@njit
-def grow_trail(seed, pattern, stats, charge, iso_mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):
+@alphapept.performance.compile_function(compilation_mode="numba")
+def grow_trail(seed, pattern, stats, charge, mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):
     """
     Wrapper to grow an isotope pattern to the left and right side
     """
@@ -739,8 +732,8 @@ def grow_trail(seed, pattern, stats, charge, iso_mass_range, sortindex_, hill_pt
     return trail
 
 
-@njit
-def get_trails(seed, pattern, stats, charge_range, iso_mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):
+@alphapept.performance.compile_function(compilation_mode="numba")
+def get_trails(seed, pattern, stats, charge_range, mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):
     """
     Wrapper to extract trails for a given charge range
     """
@@ -785,24 +778,8 @@ def plot_pattern(pattern, sorted_hills, centroids, hill_data):
 
 # Cell
 
-@njit
-def get_local_minima(y):
-    """
-    Function to return all local minima of a array
-    """
-    minima = List()
-    for i in range(1, len(y) - 1):
-        if is_local_minima(y, i):
-            minima.append(i)
-    return minima
-
-
-@njit
-def is_local_minima(y, i):
-    return (y[i - 1] > y[i]) & (y[i + 1] > y[i])
-
-@njit
-def get_minpos(y, iso_split_level=1.3):
+@alphapept.performance.compile_function(compilation_mode="numba")
+def get_minpos(y, split=5):
     """
     Function to get a list of minima in a trace.
     A minimum is returned if the ratio of lower of the surrounding maxima to the minimum is larger than the splitting factor.
@@ -824,9 +801,25 @@ def get_minpos(y, iso_split_level=1.3):
 
     return minima_list
 
+@alphapept.performance.compile_function(compilation_mode="numba")
+def get_local_minima(y):
+    """
+    Function to return all local minima of a array
+    """
+    minima = List()
+    for i in range(1, len(y) - 1):
+        if is_local_minima(y, i):
+            minima.append(i)
+    return minima
+
+
+@alphapept.performance.compile_function(compilation_mode="numba")
+def is_local_minima(y, i):
+    return (y[i - 1] > y[i]) & (y[i + 1] > y[i])
 
-@njit
-def truncate(array, intensity_profile, seedpos, iso_split_level):
+
+@alphapept.performance.compile_function(compilation_mode="numba")
+def truncate(array, intensity_profile, seedpos):
     """
     Function to truncate an intensity profile around its seedposition
     """
@@ -855,7 +848,7 @@ def truncate(array, intensity_profile, seedpos, iso_split_level):
 from .chem import mass_to_dist
 from .constants import averagine_aa, isotopes
 
-@njit
+@alphapept.performance.compile_function(compilation_mode="numba")
 def check_averagine(stats, pattern, charge, averagine_aa, isotopes):
 
     masses, intensity = pattern_to_mz(stats, pattern, charge)
@@ -869,7 +862,7 @@ def check_averagine(stats, pattern, charge, averagine_aa, isotopes):
 
     return cosine_averagine(int_one, int_two, spec_one, spec_two)
 
-@njit
+@alphapept.performance.compile_function(compilation_mode="numba")
 def pattern_to_mz(stats, pattern, charge):
     """
     Function to calculate masses and intensities from pattern for a given charge
@@ -889,7 +882,7 @@ def pattern_to_mz(stats, pattern, charge):
 
     return masses, intensity
 
-@njit
+@alphapept.performance.compile_function(compilation_mode="numba")
 def cosine_averagine(int_one, int_two, spec_one, spec_two):
 
     min_one, max_one = spec_one[0], spec_one[-1]
@@ -912,7 +905,7 @@ def cosine_averagine(int_one, int_two, spec_one, spec_two):
 
 
 
-@njit
+@alphapept.performance.compile_function(compilation_mode="numba")
 def int_list_to_array(numba_list):
     """
     Numba compatbilte function to convert a numba list with integers to a numpy array
@@ -927,7 +920,7 @@ def int_list_to_array(numba_list):
 
 M_PROTON = mass_dict['Proton']
 
-@njit
+@alphapept.performance.compile_function(compilation_mode="numba")
 def mz_to_mass(mz, charge):
     """
     Function to calculate the mass from a mz value.
@@ -939,9 +932,80 @@ def mz_to_mass(mz, charge):
 
     return mass
 
+@alphapept.performance.compile_function(compilation_mode="numba")
+def get_minpos(y, split=5):
+    """
+    Function to get a list of minima in a trace.
+    A minimum is returned if the ratio of lower of the surrounding maxima to the minimum is larger than the splitting factor.
+    """
+    minima = get_local_minima(y)
+    minima_list = List()
+
+    for minpos in minima:
+
+        minval = y[minpos]
+        left_side = y[:minpos]
+        right_side = y[minpos:]
+
+        left_max = np.max(left_side)
+        right_max = np.max(right_side)
+
+        minimum_max = np.min(np.array((left_max, right_max)))
+
+        if minimum_max / minval > split:
+            minima_list.append(minpos)
+
+    return minima_list
+
+@alphapept.performance.compile_function(compilation_mode="numba")
+def get_local_minima(y):
+    """
+    Function to return all local minima of a array
+    """
+    minima = List()
+    for i in range(1, len(y) - 1):
+        if is_local_minima(y, i):
+            minima.append(i)
+    return minima
+
+
+
+@alphapept.performance.compile_function(compilation_mode="numba")
+def is_local_minima(y, i):
+    return (y[i - 1] > y[i]) & (y[i + 1] > y[i])
+
+
+
+@alphapept.performance.compile_function(compilation_mode="numba")
+def truncate(array, intensity_profile, seedpos):
+    """
+    Function to truncate an intensity profile around its seedposition
+    """
+    minima = int_list_to_array(get_minpos(intensity_profile))
+
+    if len(minima) > 0:
+        left_minima = minima[minima < seedpos]
+        right_minima = minima[minima > seedpos]
+
+        # If the minimum is smaller than the seed
+        if len(left_minima) > 0:
+            minpos = left_minima[-1]
+        else:
+            minpos = 0
+
+        if len(right_minima) > 0:
+            maxpos = right_minima[0]
+        else:
+            maxpos = len(array)
+
+        array = array[minpos:maxpos+1]
+        intensity_profile = intensity_profile[minpos:maxpos+1]
+
+    return array, intensity_profile
+
 # Cell
-@njit
-def isolate_isotope_pattern(pre_pattern, hill_ptrs, hill_data, int_data, scan_idx, stats, sortindex_, iso_mass_range, charge_range, averagine_aa, isotopes, iso_n_seeds, cc_cutoff, iso_split_level):
+@alphapept.performance.compile_function(compilation_mode="numba")
+def isolate_isotope_pattern(pre_pattern, hill_ptrs, hill_data, int_data, scan_idx, stats, sortindex_, mass_range, charge_range, averagine_aa, isotopes, seed_masses, cc_cutoff):
     """
     Isolate isotope patterns
     """
@@ -1051,21 +1115,15 @@ def get_isotope_patterns(pre_isotope_patterns, hill_ptrs, hill_data, int_data, s
     return iso_patterns, iso_idx, np.array(isotope_charges)
 
 # Cell
-@parallel_compiled_func(cpu_only=True)
+@alphapept.performance.performance_function(compilation_mode="numba-multithread")
 def report_(idx, isotope_charges, isotope_patterns, iso_idx, stats, sortindex_, hill_ptrs, hill_data, int_data, rt_, rt_idx, results):
-    if idx == -1:
-        x = alphapept.speed.cuda.grid(1)
-    else:
-        x = idx
-
-    i = x
 
-    pattern = isotope_patterns[iso_idx[i]:iso_idx[i+1]]
+    pattern = isotope_patterns[iso_idx[idx]:iso_idx[idx+1]]
     isotope_data = stats[pattern]
 
     mz = np.min(isotope_data[:, 0])
     mz_std = np.mean(isotope_data[:, 1])
-    charge = isotope_charges[i]
+    charge = isotope_charges[idx]
     mass = mz_to_mass(mz, charge)
     int_max_idx = np.argmax(isotope_data[:, 2])
     mz_most_abundant = isotope_data[:, 0][int_max_idx]
@@ -1106,10 +1164,6 @@ def report_(idx, isotope_charges, isotope_patterns, iso_idx, stats, sortindex_,
 
         trace_sum += interpolation
 
-
-        #plt.plot(rt_range, interpolation)
-
-
     rt_apex_idx = trace_sum.argmax()
     rt_apex = rt_range[rt_apex_idx]
 
@@ -1123,7 +1177,6 @@ def report_(idx, isotope_charges, isotope_patterns, iso_idx, stats, sortindex_,
     right_apex = np.abs(trace[rt_apex_idx:]-half_max).argmin()+rt_apex_idx
 
     int_apex = trace_sum[rt_apex_idx]
-
     fwhm = rt_range[right_apex] - rt_range[left_apex]
 
     n_isotopes = len(pattern)
@@ -1151,15 +1204,10 @@ def report_(idx, isotope_charges, isotope_patterns, iso_idx, stats, sortindex_,
     rt_start = rt_range[rt_min_idx]
     rt_end = rt_range[rt_max_idx]
 
-
-
-
     int_area = np.trapz(trace_sum[rt_min_idx:rt_max_idx], rt_range[rt_min_idx:rt_max_idx])
     int_sum = trace_sum.sum()
 
-    results[i,:] = np.array([mz, mz_std, mz_most_abundant, charge, rt_start, rt_apex, rt_end, fwhm, n_isotopes, mass, int_apex, int_area, int_sum])
-
-
+    results[idx,:] = np.array([mz, mz_std, mz_most_abundant, charge, rt_start, rt_apex, rt_end, fwhm, n_isotopes, mass, int_apex, int_area, int_sum])
 
 # Cell
 import pandas as pd
@@ -1174,7 +1222,7 @@ def feature_finder_report(query_data, isotope_patterns, isotope_charges, iso_idx
 
     results = np.zeros((len(isotope_charges), 13))
 
-    report_(isotope_charges, isotope_patterns, iso_idx, stats, sortindex_, hill_ptrs, hill_data, int_data, rt_, rt_idx, results)
+    report_(range(len(isotope_charges)), isotope_charges, isotope_patterns, iso_idx, stats, sortindex_, hill_ptrs, hill_data, int_data, rt_, rt_idx, results)
 
     df = pd.DataFrame(results, columns = ['mz','mz_std','mz_most_abundant','charge','rt_start','rt_apex','rt_end','fwhm','n_isotopes','mass','int_apex','int_area', 'int_sum'])
 
@@ -1490,12 +1538,14 @@ def find_features(to_process, callback = None, parallel = False):
                     iso_corr_min = f_settings['iso_corr_min']
 
                     logging.info('Feature finding on {}'.format(file_name))
-                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap = max_gap, centroid_tol = centroid_tol)
+                    logging.info(f'Hill extraction with ppm_tol {ppm_tol} and max_gap {max_gap}')
+
+                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap, ppm_tol)
                     logging.info(f'Number of hills {len(hill_ptrs):,}, len = {np.mean(path_node_cnt):.2f}')
 
                     logging.info(f'Repeating hill extraction with centroid_tol {score_median+score_std*3:.2f}')
 
-                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap = max_gap, centroid_tol = score_median+score_std*3)
+                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap, score_median+score_std*3)
                     logging.info(f'Number of hills {len(hill_ptrs):,}, len = {np.mean(path_node_cnt):.2f}')
 
                     int_data = np.array(query_data['int_list_ms1'])
diff --git a/alphapept/gui/results.py b/alphapept/gui/results.py
index b1a7ff78..638dac7a 100644
--- a/alphapept/gui/results.py
+++ b/alphapept/gui/results.py
@@ -293,8 +293,6 @@ def results():
 
     #TOdo: include previously processed output files..
 
-    file = ''
-
     selection = st.selectbox('File selection', ('Previous results', 'Enter file'))
 
     if selection == 'Previous results':
@@ -321,7 +319,7 @@ def results():
     elif selection == 'Enter file':
         file = st.text_input("Enter path to hdf file.", os.getcwd())
     else:
-        pass
+        file = ''
 
     if file is None:
         file = ''
diff --git a/alphapept/interface.py b/alphapept/interface.py
index 08aa83bb..c10a463f 100644
--- a/alphapept/interface.py
+++ b/alphapept/interface.py
@@ -19,7 +19,6 @@
 import psutil
 
 def parallel_execute(settings, step, callback=None):
-
     """
     Generic function to parallel execute worklow steps on a file basis
 
diff --git a/alphapept/performance.py b/alphapept/performance.py
new file mode 100644
index 00000000..9dfd51a8
--- /dev/null
+++ b/alphapept/performance.py
@@ -0,0 +1,386 @@
+# AUTOGENERATED! DO NOT EDIT! File to edit: nbs/13_performance.ipynb (unless otherwise specified).
+
+__all__ = ['COMPILATION_MODE_OPTIONS', 'is_valid_compilation_mode', 'set_worker_count', 'MAX_WORKER_COUNT',
+           'set_compilation_mode', 'compile_function', '__copy_func', 'DYNAMIC_COMPILATION_ENABLED',
+           'performance_function']
+
+# Cell
+
+COMPILATION_MODE_OPTIONS = [
+    "python",
+    "python-multithread",
+    "numba",
+    "numba-multithread",
+    "cuda", # Cuda is always multithreaded
+]
+
+# Cell
+
+import functools
+import math
+import os
+import logging
+import psutil
+import ast
+
+# Parallelization
+import multiprocessing
+import threading
+
+# Compilation
+import numpy as np
+import numba
+from numba import cuda
+try:
+    import cupy
+    cuda.get_current_device()
+    __GPU_AVAILABLE = True
+except ModuleNotFoundError:
+    __GPU_AVAILABLE = False
+    cupy = None
+    logging.info("Cupy is not available")
+except cuda.CudaSupportError:
+    __GPU_AVAILABLE = False
+    logging.info("Cuda device is not available")
+
+def is_valid_compilation_mode(compilation_mode: str) -> None:
+    """Check if the provided string is a valid compilation mode.
+
+    Parameters
+    ----------
+    compilation_mode : str
+        The compilation mode to verify.
+
+    Raises
+    ------
+    ModuleNotFoundError
+        When trying to use an unavailable GPU.
+    NotImplementedError
+        When the compilation mode is not valid.
+    """
+    if compilation_mode.startswith("cuda") and not __GPU_AVAILABLE:
+        raise ModuleNotFoundError('Cuda functions are not available.')
+    if compilation_mode not in COMPILATION_MODE_OPTIONS:
+        raise NotImplementedError(
+            f"Compilation mode '{compilation_mode}' is not available, "
+            "see COMPILATION_MODE_OPTIONS for available options."
+        )
+
+# Cell
+
+if __GPU_AVAILABLE:
+    COMPILATION_MODE = "cuda"
+else:
+    COMPILATION_MODE = "numba-multithread"
+
+# Cell
+
+MAX_WORKER_COUNT = psutil.cpu_count()
+
+def set_worker_count(worker_count: int = 1, set_global: bool = True) -> int:
+    """Parse and set the (global) number of threads.
+
+    Parameters
+    ----------
+    worker_count : int
+        The number of workers.
+        If larger than available cores, it is trimmed to the available maximum.
+        If 0, it is set to the maximum cores available.
+        If negative, it indicates how many cores NOT to use.
+        Default is 1
+    set_global : bool
+        If False, the number of workers is only parsed to a valid value.
+        If True, the number of workers is saved as a global variable.
+        Default is True.
+
+    Returns
+    -------
+    : int
+        The parsed worker_count.
+    """
+    max_cpu_count = psutil.cpu_count()
+    if worker_count > max_cpu_count:
+        worker_count = max_cpu_count
+    else:
+        while worker_count <= 0:
+            worker_count += max_cpu_count
+    if set_global:
+        global MAX_WORKER_COUNT
+        MAX_WORKER_COUNT = worker_count
+    return worker_count
+
+# Cell
+
+DYNAMIC_COMPILATION_ENABLED = False
+
+def set_compilation_mode(
+    compilation_mode: str = None,
+    enable_dynamic_compilation: bool = None,
+) -> None:
+    """Set the global compilation mode to use.
+
+    Parameters
+    ----------
+    compilation_mode : str
+        The compilation mode to use.
+        Will be checked with `is_valid_compilation_mode`.
+        Default is None
+    enable_dynamic_compilation : bool
+        Enable dynamic compilation.
+        If enabled, code will generally be slower and no other functions can
+        be called from within a compiled function anymore, as they are compiled at runtime.
+        WARNING: Enabling this is strongly disadvised in almost all cases!
+        Default is None.
+    """
+    if enable_dynamic_compilation is not None:
+        global DYNAMIC_COMPILATION_ENABLED
+        DYNAMIC_COMPILATION_ENABLED = enable_dynamic_compilation
+    if compilation_mode is not None:
+        is_valid_compilation_mode(compilation_mode)
+        global COMPILATION_MODE
+        COMPILATION_MODE = compilation_mode
+
+
+def compile_function(
+    _func: callable = None,
+    *,
+    compilation_mode: str = None,
+    **decorator_kwargs,
+) -> callable:
+    """A decorator to compile a given function.
+
+    Numba functions are by default set to use `nogil=True` and `nopython=True`,
+    unless explicitly defined otherwise.
+    Cuda functions are by default set to use `device=True`,
+    unless explicitly defined otherwise..
+
+    Parameters
+    ----------
+    compilation_mode : str
+        The compilation mode to use.
+        Will be checked with `is_valid_compilation_mode`.
+        If None, the global COMPILATION_MODE will be used as soon as the function is decorated for static compilation.
+        If DYNAMIC_COMPILATION_ENABLED, the function will always be compiled at runtime and
+        thus by default returns a Python function.
+        Static recompilation can be enforced by reimporting a module containing
+        the function with importlib.reload(imported_module).
+        If COMPILATION_MODE is Python and not DYNAMIC_COMPILATION_ENABLED, no compilation will be used.
+        Default is None
+    **decorator_kwargs
+        Keyword arguments that will be passed to numba.jit or cuda.jit compilation decorators.
+    """
+    if compilation_mode is None:
+        if DYNAMIC_COMPILATION_ENABLED:
+            compilation_mode = "dynamic"
+        else:
+            compilation_mode = COMPILATION_MODE
+    elif COMPILATION_MODE.startswith("python"):
+        compilation_mode = "python"
+    else:
+        is_valid_compilation_mode(compilation_mode)
+    def parse_compilation(current_compilation_mode, func):
+        if current_compilation_mode.startswith("python"):
+            compiled_function = __copy_func(func)
+        elif current_compilation_mode.startswith("numba"):
+            if "nogil" in decorator_kwargs:
+                if "nopython" in decorator_kwargs:
+                    compiled_function = numba.jit(func, **decorator_kwargs)
+                else:
+                    compiled_function = numba.jit(func, **decorator_kwargs, nopython=True)
+            elif "nopython" in decorator_kwargs:
+                compiled_function = numba.jit(func, **decorator_kwargs, nogil=True)
+            else:
+                compiled_function = numba.jit(func, **decorator_kwargs, nogil=True, nopython=True)
+        elif current_compilation_mode.startswith("cuda"):
+            if "device" in decorator_kwargs:
+                compiled_function = cuda.jit(func, **decorator_kwargs)
+            else:
+                compiled_function = cuda.jit(func, **decorator_kwargs, device=True)
+        return compiled_function
+    def decorated_function(func):
+        if compilation_mode != "dynamic":
+            is_valid_compilation_mode(compilation_mode)
+            static_compiled_function = parse_compilation(compilation_mode, func)
+            return functools.wraps(func)(static_compiled_function)
+        else:
+            def dynamic_compiled_function(*func_args, **func_kwargs):
+                compiled_function = parse_compilation(COMPILATION_MODE, func)
+                return compiled_function(*func_args, **func_kwargs)
+            return functools.wraps(func)(dynamic_compiled_function)
+    if _func is None:
+        return decorated_function
+    else:
+        return decorated_function(_func)
+
+
+import types
+import functools
+
+def __copy_func(f):
+    """Based on http://stackoverflow.com/a/6528148/190597 (Glenn Maynard)"""
+    g = types.FunctionType(f.__code__, f.__globals__, name=f.__name__,
+                           argdefs=f.__defaults__,
+                           closure=f.__closure__)
+    g = functools.update_wrapper(g, f)
+    g.__kwdefaults__ = f.__kwdefaults__
+    return g
+
+# Cell
+
+def performance_function(
+    _func: callable = None,
+    *,
+    worker_count: int = None,
+    compilation_mode: str = None,
+    **decorator_kwargs,
+) -> callable:
+    """A decorator to compile a given function and allow multithreading over an multiple indices.
+
+    NOTE This should only be used on functions that are compilable.
+    Functions that need to be decorated need to have an `index` argument as first argument.
+    If an iterable is provided to the decorated function,
+    the original (compiled) function will be applied to all elements of this iterable.
+    The most efficient way to provide iterables are with ranges, but numpy arrays work as well.
+    Functions can not return values,
+    results should be stored in buffer arrays inside thge function instead.
+
+    Parameters
+    ----------
+    worker_count : int
+        The number of workers to use for multithreading.
+        If None, the global MAX_WORKER_COUNT is used at runtime.
+        Default is None.
+    compilation_mode : str
+        The compilation mode to use. Will be forwarded to the `compile_function` decorator.
+    **decorator_kwargs
+        Keyword arguments that will be passed to numba.jit or cuda.jit compilation decorators.
+    """
+    if worker_count is not None:
+        worker_count = set_worker_count(worker_count, set_global=False)
+    if compilation_mode is None:
+        if DYNAMIC_COMPILATION_ENABLED:
+            compilation_mode = "dynamic"
+        else:
+            compilation_mode = COMPILATION_MODE
+    elif COMPILATION_MODE.startswith("python"):
+        compilation_mode = "python"
+    else:
+        is_valid_compilation_mode(compilation_mode)
+    def _decorated_function(func):
+        if compilation_mode != "dynamic":
+            compiled_function = compile_function(
+                func,
+                compilation_mode=compilation_mode,
+                **decorator_kwargs
+            )
+        def _parallel_python(
+            compiled_function,
+            iterable,
+            start,
+            stop,
+            step,
+            *func_args
+        ):
+            if start != -1:
+                for index in range(start, stop, step):
+                    compiled_function(index, *func_args)
+            else:
+                for index in iterable:
+                    compiled_function(index, *func_args)
+        _parallel_numba = numba.njit(nogil=True)(_parallel_python)
+        def _parallel_cuda(compiled_function, iterable, *func_args):
+            cuda_func_dict = {"cuda": cuda, "compiled_function": compiled_function}
+            # Cuda functions cannot handle tuple unpacking but need a fixed number of arguments.
+            if isinstance(iterable, range):
+                func_string = ", ".join(f"arg{i}" for i in range(len(func_args) + 3))
+                cuda_string = (
+                    f"@cuda.jit\n"
+                    f"def cuda_func({func_string}):\n"
+                    f"    index = arg0 + arg2 * cuda.grid(1)\n"
+                    f"    compiled_function(index, {func_string[18:]})\n"
+                )
+                exec(cuda_string, cuda_func_dict)
+                cuda_func_dict["cuda_func"].forall(len(iterable), 1)(
+                    iterable.start,
+                    iterable.stop,
+                    iterable.step,
+                    *func_args
+                )
+            else:
+                func_string = ", ".join(f"arg{i}" for i in range(len(func_args) + 1))
+                cuda_string = (
+                    f"@cuda.jit\n"
+                    f"def cuda_func({func_string}):\n"
+                    f"    index = arg0[cuda.grid(1)]\n"
+                    f"    compiled_function(index, {func_string[6:]})\n"
+                )
+                exec(cuda_string, cuda_func_dict)
+                cuda_func_dict["cuda_func"].forall(len(iterable), 1)(iterable, *func_args)
+        def _performance_function(iterable, *func_args):
+            if compilation_mode == "dynamic":
+                selected_compilation_mode = COMPILATION_MODE
+                _compiled_function = compile_function(
+                    func,
+                    compilation_mode=selected_compilation_mode,
+                    **decorator_kwargs
+                )
+            else:
+                _compiled_function = compiled_function
+                selected_compilation_mode = compilation_mode
+            try:
+                iter(iterable)
+            except TypeError:
+                iterable = np.array([iterable])
+            if worker_count is None:
+                selected_worker_count = MAX_WORKER_COUNT
+            else:
+                selected_worker_count = worker_count
+            if selected_compilation_mode == "cuda":
+                _parallel_cuda(_compiled_function, iterable, *func_args)
+            else:
+                if "python" in selected_compilation_mode:
+                    parallel_function = _parallel_python
+                elif "numba" in selected_compilation_mode:
+                    parallel_function = _parallel_numba
+                else:
+                    raise NotImplementedError(
+                        f"Compilation mode {selected_compilation_mode} is not valid. "
+                        "This error should not be possible, something is seriously wrong!!!"
+                    )
+                if (selected_compilation_mode in ["python", "numba"]) or (selected_worker_count == 1):
+                    iterable_is_range = isinstance(iterable, range)
+                    x = np.empty(0, dtype=np.int64) if iterable_is_range else iterable
+                    parallel_function(
+                        _compiled_function,
+                        np.empty(0, dtype=np.int64) if iterable_is_range else iterable,
+                        iterable.start if iterable_is_range else -1,
+                        iterable.stop if iterable_is_range else -1,
+                        iterable.step if iterable_is_range else -1,
+                        *func_args
+                    )
+                else:
+                    workers = []
+                    for worker_id in range(selected_worker_count):
+                        local_iterable = iterable[worker_id::selected_worker_count]
+                        iterable_is_range = isinstance(local_iterable, range)
+                        worker = threading.Thread(
+                            target=parallel_function,
+                            args=(
+                                _compiled_function,
+                                np.empty(0, dtype=np.int64) if iterable_is_range else local_iterable,
+                                local_iterable.start if iterable_is_range else -1,
+                                local_iterable.stop if iterable_is_range else -1,
+                                local_iterable.step if iterable_is_range else -1,
+                                *func_args
+                            )
+                        )
+                        worker.start()
+                        workers.append(worker)
+                    for worker in workers:
+                        worker.join()
+                        del worker
+        return functools.wraps(func)(_performance_function)
+    if _func is None:
+        return _decorated_function
+    else:
+        return _decorated_function(_func)
\ No newline at end of file
diff --git a/alphapept/speed.py b/alphapept/speed.py
index 2b7072ec..fc7c96fd 100644
--- a/alphapept/speed.py
+++ b/alphapept/speed.py
@@ -1,36 +1,88 @@
 # AUTOGENERATED! DO NOT EDIT! File to edit: nbs/12_speed.ipynb (unless otherwise specified).
 
-__all__ = ['grid_1d', 'grid_2d', 'set_cuda_grid', 'parallel_compiled_func', 'set_max_process', 'AlphaPool', 'numba',
-           'cuda']
+__all__ = ['dummy_decorator', 'set_speed_mode', 'grid_1d', 'grid_2d', 'set_cuda_grid', 'numba_threaded',
+           'parallel_compiled_func', 'set_max_process', 'AlphaPool', 'cupy', 'cuda', 'numba', 'jit_fun', 'jit_fun_gpu',
+           'speed_mode']
 
 # Cell
-
-# A decorator for writing GPU/CPU agnostic code
 import multiprocessing
 import threading
 import functools
 import math
-import numba as numba_
-numba = numba_
 import numpy as np
-from numba import cuda as cuda_
-cuda = cuda_
 from numba import njit
+import psutil
+from multiprocessing import Pool
+import os
 import logging
 
+import numpy as numpy_
+cupy = numpy_
+from numba import cuda as cuda_
+cuda = cuda_
+import numba as numba_
+numba = numba_
+
+# We use jit_fun and jit_fun_gpu.
+# This is to be able too distinguish GPU device functions and numba optimized functions in a GPU setting
+
+jit_fun = None
+jit_fun_gpu = None
+speed_mode = None
+
+
 try:
-    import cupy
-    jit_fun = cuda.jit(device=True) #Device Function
+    import cupy as cupy_
 except ModuleNotFoundError:
-    import numpy as cupy
-    jit_fun = njit
+    cupy_ = None
+
+def dummy_decorator(func):
+    """
+    Dummy decorator that does nothing
+    """
+    return func
+
+def set_speed_mode(mode):
+    """
+    Function to change
+
+    """
+    global jit_fun
+    global jit_fun_gpu
+    global speed_mode
+    global cupy
+
+    speed_mode = mode
+
+    if mode == 'python':
+        jit_fun = dummy_decorator
+        jit_fun_gpu = dummy_decorator
+        cupy = numpy_
+    elif mode == 'numba':
+        jit_fun = njit
+        jit_fun_gpu = njit
+        cupy = numpy_
+    elif mode == 'numba_gpu':
+        jit_fun = njit
+        jit_fun_gpu = cuda.jit(device=True)
+        if cupy_ is not None:
+            cupy = cupy_
+        else:
+            raise ModuleNotFoundError('Cupy not installed.')
+    else:
+        raise NotImplementedError(mode)
+
+
+if cupy_ is not None:
+    set_speed_mode('numba_gpu')
+else:
+    set_speed_mode('numba')
 
 @numba.njit
 def grid_1d(x): return -1
 @numba.njit
 def grid_2d(x): return -1, -1
 
-
 def set_cuda_grid(dimensions=0):
     global cuda
     if dimensions == 0:
@@ -43,14 +95,53 @@ def set_cuda_grid(dimensions=0):
         cuda = numba_
         cuda.grid = grid_2d
 
+
+def numba_threaded(_func=None, *, cpu_threads=0):
+    if cpu_threads <= 0:
+        cpu_threads = multiprocessing.cpu_count()
+
+    def parallel_compiled_func_inner(func):
+        if speed_mode == 'python':
+            numba_func = func
+        else:
+            numba_func = numba.njit(nogil=True)(func)
+
+        def numba_func_parallel(thread, iterable, *args):
+            for i in range(thread, len(iterable), cpu_threads):
+                numba_func(i, iterable, *args)
+
+        if speed_mode == 'python':
+            numba_func_parallel = numba_func_parallel
+        else:
+            numba_func_parallel = numba.njit(nogil=True)(numba_func_parallel)
+
+        def wrapper(iterable, *args):
+            threads = []
+            for thread_id in range(cpu_threads):
+                t = threading.Thread(
+                    target=numba_func_parallel,
+                    args=(thread_id, iterable, *args)
+                )
+                t.start()
+                threads.append(t)
+            for t in threads:
+                t.join()
+                del t
+        return functools.wraps(func)(wrapper)
+
+    if _func is None:
+        return parallel_compiled_func_inner
+    else:
+        return parallel_compiled_func_inner(_func)
+
+
 def parallel_compiled_func(
     _func=None,
     *,
     cpu_threads=None,
     dimensions=1,
-    cpu_only = False,
 ):
-    set_cuda_grid()
+    #set_cuda_grid()
     if dimensions not in (1, 2):
         raise ValueError("Only 1D and 2D are supported")
 
@@ -58,8 +149,8 @@ def parallel_compiled_func(
         use_gpu = False
     else:
         try:
-            cuda.get_current_device()
-        except cuda.CudaSupportError:
+            cuda_.get_current_device()
+        except cuda_.CudaSupportError:
             use_gpu = False
             cpu_threads = 0
         else:
@@ -70,10 +161,18 @@ def parallel_compiled_func(
             use_gpu = False
             cpu_threads = 0
 
-    if cpu_only:
+    if cpu_threads is None:
+        cpu_threads = multiprocessing.cpu_count()
+
+    if cpu_threads <= 0:
+        cpu_threads = multiprocessing.cpu_count()
+
+    if speed_mode == 'numba_gpu':
+        use_gpu = True
+    elif speed_mode == 'numba':
+        use_gpu = False
+    elif speed_mode == 'python':
         use_gpu = False
-        if cpu_threads is None:
-            cpu_threads = 0
 
     if use_gpu:
         set_cuda_grid()
@@ -111,7 +210,12 @@ def wrapper(iterable_2d, *args):
         if cpu_threads <= 0:
             cpu_threads = multiprocessing.cpu_count()
         def parallel_compiled_func_inner(func):
-            numba_func = numba.njit(nogil=True)(func)
+
+            if speed_mode == 'python':
+                numba_func = func
+            else:
+                numba_func = numba.njit(nogil=True)(func)
+
             if dimensions == 1:
                 def numba_func_parallel(
                     thread,
@@ -137,7 +241,12 @@ def numba_func_parallel(
                     ):
                         for j in range(iterable_2d.shape[1]):
                             numba_func(i, j, iterable_2d, *args)
-            numba_func_parallel = numba.njit(nogil=True)(numba_func_parallel)
+
+            if speed_mode == 'python':
+                numba_func_parallel = numba_func_parallel
+            else:
+                numba_func_parallel = numba.njit(nogil=True)(numba_func_parallel)
+
             def wrapper(iterable, *args):
                 threads = []
                 for thread_id in range(cpu_threads):
@@ -156,10 +265,6 @@ def wrapper(iterable, *args):
     else:
         return parallel_compiled_func_inner(_func)
 
-import psutil
-from multiprocessing import Pool
-import os
-
 def set_max_process(a):
     max_processes = psutil.cpu_count()
     new_max = min(a, max_processes)
diff --git a/docs/_config.yml b/docs/_config.yml
index 846c0208..ec9cc555 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -60,4 +60,6 @@ sidebars:
 - home_sidebar
 
 theme: jekyll-theme-cayman
-baseurl: /
\ No newline at end of file
+
+baseurl: /alphapept/
+
diff --git a/nbs/04_feature_finding.ipynb b/nbs/04_feature_finding.ipynb
index 897e1d78..c849f53a 100644
--- a/nbs/04_feature_finding.ipynb
+++ b/nbs/04_feature_finding.ipynb
@@ -64,20 +64,14 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "import alphapept.speed\n",
     "import numpy as np\n",
-    "from alphapept.speed import parallel_compiled_func, cupy, jit_fun\n",
-    "\n",
-    "@parallel_compiled_func\n",
-    "def connect_centroids_unidirection(idx, row_borders, connections, scores, centroids, max_gap, centroid_tol):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
+    "import alphapept.performance\n",
     "\n",
+    "@alphapept.performance.performance_function\n",
+    "def connect_centroids_unidirection(x, row_borders, connections, scores, centroids, max_gap, ppm_tol):\n",
     "    for gap in range(max_gap + 1):\n",
     "        y = x + gap + 1\n",
-    "        if y > row_borders.shape[0]:\n",
+    "        if y >= row_borders.shape[0]:\n",
     "            return\n",
     "\n",
     "        start_index_f = 0\n",
@@ -87,6 +81,7 @@
     "        centroids_1 = centroids[start_index_f: row_borders[x]]\n",
     "\n",
     "        start_index_b = row_borders[y - 1]\n",
+    "        \n",
     "        centroids_2 = centroids[start_index_b: row_borders[y]]\n",
     "\n",
     "        i = 0\n",
@@ -109,13 +104,8 @@
     "            else:\n",
     "                i += 1\n",
     "\n",
-    "@parallel_compiled_func\n",
-    "def convert_connections_to_array(idx, from_r, from_c, to_r, to_c, row_borders, out_from_idx, out_to_idx):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
-    "\n",
+    "@alphapept.performance.performance_function\n",
+    "def convert_connections_to_array(x, from_r, from_c, to_r, to_c, row_borders, out_from_idx, out_to_idx):\n",
     "    row = from_r[x]\n",
     "    col = from_c[x]\n",
     "    start_index_f = 0\n",
@@ -130,47 +120,29 @@
     "        start_index_f = row_borders[row - 1]\n",
     "    out_to_idx[x] = start_index_f + col\n",
     "    \n",
-    "@parallel_compiled_func\n",
-    "def eliminate_overarching_vertex(idx, from_idx, to_idx):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
+    "@alphapept.performance.performance_function\n",
+    "def eliminate_overarching_vertex(x, from_idx, to_idx):\n",
     "    if x == 0:\n",
     "        return\n",
     "\n",
     "    if from_idx[x - 1] == from_idx[x]:\n",
     "        to_idx[x] = -1\n",
     "        \n",
-    "@parallel_compiled_func\n",
-    "def path_finder(idx, from_idx, to_idx, forward, backward):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
+    "@alphapept.performance.performance_function\n",
+    "def path_finder(x, from_idx, to_idx, forward, backward):\n",
     "    fr = from_idx[x]\n",
     "    to =  to_idx[x]\n",
     "\n",
     "    forward[fr] = to\n",
     "    backward[to] = fr\n",
     "\n",
-    "@parallel_compiled_func\n",
-    "def find_path_start(idx, forward, backward, path_starts):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
-    "\n",
+    "@alphapept.performance.performance_function\n",
+    "def find_path_start(x, forward, backward, path_starts):\n",
     "    if forward[x] > -1 and backward[x] == -1:\n",
     "        path_starts[x] = 0\n",
     "\n",
-    "@parallel_compiled_func\n",
-    "def find_path_length(idx, path_starts, forward, path_cnt):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
-    "\n",
+    "@alphapept.performance.performance_function\n",
+    "def find_path_length(x, path_starts, forward, path_cnt):\n",
     "    ctr = 1\n",
     "    idx = path_starts[x]\n",
     "    while forward[idx] > -1:\n",
@@ -178,13 +150,8 @@
     "        idx = forward[idx]\n",
     "    path_cnt[x] = ctr\n",
     "\n",
-    "@parallel_compiled_func\n",
-    "def fill_path_matrix(idx, path_start, forwards, out_hill_data, out_hill_ptr):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
-    "        \n",
+    "@alphapept.performance.performance_function\n",
+    "def fill_path_matrix(x, path_start, forwards, out_hill_data, out_hill_ptr):\n",
     "    path_position = 0\n",
     "    idx = path_start[x]\n",
     "    while idx > -1:\n",
@@ -192,19 +159,13 @@
     "        idx = forwards[idx]\n",
     "        path_position += 1\n",
     "\n",
-    "@parallel_compiled_func\n",
-    "def convert_to_coord_path(idx, out_rows, out_cols, connected_comps, d_width):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
-    "\n",
-    "    idx = connected_comps[x]\n",
-    "    row = idx // d_width\n",
-    "    col = idx - row * d_width\n",
-    "\n",
-    "    out_rows[x] = row\n",
-    "    out_cols[x] = col"
+    "# @alphapept.performance.performance_function\n",
+    "# def convert_to_coord_path(x, out_rows, out_cols, connected_comps, d_width):\n",
+    "#     idx = connected_comps[x]\n",
+    "#     row = idx // d_width\n",
+    "#     col = idx - row * d_width\n",
+    "#     out_rows[x] = row\n",
+    "#     out_cols[x] = col"
    ]
   },
   {
@@ -305,16 +266,23 @@
    "source": [
     "#export\n",
     "\n",
-    "def find_centroid_connections(rowwise_peaks, row_borders, centroids, max_gap=2, centroid_tol=8):\n",
+    "def find_centroid_connections(rowwise_peaks, row_borders, centroids, max_gap, ppm_tol):\n",
+    "    if alphapept.performance.COMPILATION_MODE == \"cuda\":\n",
+    "        import cupy\n",
+    "        cupy = cupy\n",
+    "    else:\n",
+    "        import numpy\n",
+    "        cupy = numpy\n",
+    "    \n",
+
     "    max_centroids = int(cupy.max(rowwise_peaks))\n",
     "    spectra_cnt = len(row_borders) - 1\n",
     "\n",
     "    connections = cupy.full((spectra_cnt, max_centroids, max_gap + 1), -1, dtype=np.int32)\n",
     "    score = cupy.full((spectra_cnt, max_centroids, max_gap + 1), np.inf)\n",
     "    \n",
-    "    \n",
-    "\n",
-    "    connect_centroids_unidirection(row_borders,\n",
+    "    connect_centroids_unidirection(range(len(row_borders)),\n",
+    "                                    row_borders,\n",
     "                                   connections,\n",
     "                                   score,\n",
     "                                   centroids,\n",
@@ -325,8 +293,7 @@
     "    \n",
     "    score_median = cupy.median(score)\n",
     "    score_std = cupy.std(score)\n",
-    "    \n",
-    "    \n",
+    "\n",
     "    del score, max_centroids, spectra_cnt\n",
     "\n",
     "    c_shape = connections.shape\n",
@@ -335,6 +302,7 @@
     "    to_c = connections[from_r, from_c, from_g] - to_r * c_shape[1]\n",
     "\n",
     "    del connections, from_g\n",
+    "    \n",
     "    return from_r, from_c, to_r, to_c, score_median, score_std"
    ]
   },
@@ -346,23 +314,30 @@
    "source": [
     "#export \n",
     "\n",
-    "def get_hills(centroids, from_idx, to_idx, hill_length_min=3):\n",
+    "def get_hills(centroids, from_idx, to_idx, min_hill_length=3):\n",
+    "    if alphapept.performance.COMPILATION_MODE == \"cuda\":\n",
+    "        import cupy\n",
+    "        cupy = cupy\n",
+    "    else:\n",
+    "        import numpy\n",
+    "        cupy = numpy\n",
+    "        \n",
     "    forward = cupy.full(centroids.shape[0], -1)\n",
     "    backward = cupy.full(centroids.shape[0], -1)\n",
     "    path_starts = cupy.full(centroids.shape[0], -1)\n",
     "\n",
-    "    path_finder(from_idx, to_idx, forward, backward)\n",
-    "    find_path_start(forward, backward, path_starts)\n",
+    "    path_finder(range(len(from_idx)), from_idx, to_idx, forward, backward)\n",
+    "    find_path_start(range(len(forward)), forward, backward, path_starts)\n",
     "\n",
     "    # path_starts will now container the first index of all connected centroids\n",
     "    path_starts = cupy.where(path_starts == 0)[0]\n",
     "\n",
     "    path_node_cnt = cupy.full(path_starts.shape[0], -1)\n",
-    "    find_path_length(path_starts, forward, path_node_cnt)\n",
+    "    find_path_length(range(len(path_starts)), path_starts, forward, path_node_cnt)\n",
     "\n",
     "    relavant_path_node = cupy.where(path_node_cnt >= hill_length_min)[0]\n",
     "    path_starts = cupy.take(path_starts, relavant_path_node)\n",
-    "    path_node_cnt = cupy.take( path_node_cnt, relavant_path_node)\n",
+    "    path_node_cnt = cupy.take(path_node_cnt, relavant_path_node)\n",
     "    del relavant_path_node\n",
     "\n",
     "    # Generate the hill matix indice ptr data\n",
@@ -372,7 +347,7 @@
     "    hill_ptrs[1:] = path_node_cnt.cumsum()\n",
     "    hill_data = cupy.empty((int(hill_ptrs[-1])), np.int32)\n",
     "\n",
-    "    fill_path_matrix(path_starts, forward, hill_data, hill_ptrs)\n",
+    "    fill_path_matrix(range(len(path_starts)), path_starts, forward, hill_data, hill_ptrs)\n",
     "\n",
     "    del from_idx, to_idx, path_starts, forward, backward\n",
     "    return hill_ptrs, hill_data, path_node_cnt"
@@ -385,18 +360,25 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "def connect_centroids(rowwise_peaks, row_borders, centroids, max_gap=2, centroid_tol=8):\n",
-    "\n",
+    "def connect_centroids(rowwise_peaks, row_borders, centroids, max_gap, ppm_tol):\n",
+    "    if alphapept.performance.COMPILATION_MODE == \"cuda\":\n",
+    "        import cupy\n",
+    "        cupy = cupy\n",
+    "    else:\n",
+    "        import numpy\n",
+    "        cupy = numpy\n",
+    "    \n",
     "    from_r, from_c, to_r, to_c, score_median, score_std = find_centroid_connections(rowwise_peaks,\n",
     "                                                           row_borders,\n",
     "                                                           centroids,\n",
-    "                                                           max_gap=max_gap,\n",
-    "                                                           centroid_tol=centroid_tol)\n",
+    "                                                           max_gap,\n",
+    "                                                           ppm_tol)\n",
     "\n",
     "    from_idx = cupy.zeros(len(from_r), np.int32)\n",
     "    to_idx = cupy.zeros(len(from_r), np.int32)\n",
     "\n",
-    "    convert_connections_to_array(from_r,\n",
+    "    convert_connections_to_array(range(len(from_r)),\n",
+    "                                    from_r,\n",
     "                                 from_c,\n",
     "                                 to_r,\n",
     "                                 to_c,\n",
@@ -404,7 +386,7 @@
     "                                 from_idx,\n",
     "                                 to_idx)\n",
     "\n",
-    "    eliminate_overarching_vertex(from_idx, to_idx)\n",
+    "    eliminate_overarching_vertex(range(len(from_idx)), from_idx, to_idx)\n",
     "\n",
     "    relavent_idx = cupy.where(to_idx >= 0)\n",
     "    from_idx = cupy.take(from_idx, relavent_idx)[0]\n",
@@ -421,15 +403,24 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "def extract_hills(query_data, max_gap = 2, centroid_tol = 8):\n",
+    "def extract_hills(query_data, max_gap, ppm_tol):\n",
     "\n",
+    "    if alphapept.performance.COMPILATION_MODE == \"cuda\":\n",
+    "        import cupy\n",
+    "        cupy = cupy\n",
+    "    else:\n",
+    "        import numpy\n",
+    "        cupy = numpy\n",
+    "        \n",
     "    indices = cupy.array(query_data['indices_ms1'])\n",
     "    mass_data = cupy.array(query_data['mass_list_ms1'])\n",
     "\n",
     "    rowwise_peaks = indices[1:] - indices[:-1]\n",
     "    row_borders = indices[1:]\n",
     "\n",
-    "    from_idx, to_idx, score_median, score_std = connect_centroids(rowwise_peaks, row_borders, mass_data, max_gap=max_gap, centroid_tol=centroid_tol)\n",
+    "    from_idx, to_idx, score_median, score_std = connect_centroids(rowwise_peaks, row_borders, mass_data, max_gap, ppm_tol)\n",
+    "\n",
+    "\n",
     "    hill_ptrs, hill_data, path_node_cnt = get_hills(mass_data, from_idx, to_idx)\n",
     "\n",
     "    del mass_data\n",
@@ -442,10 +433,8 @@
     "        \n",
     "        score_median = score_median.get()\n",
     "        score_std = score_std.get()\n",
-    "\n",
-    "    \n",
-    "    return hill_ptrs, hill_data, path_node_cnt, score_median, score_std\n",
-    "\n"
+    "        \n",
+    "    return hill_ptrs, hill_data, path_node_cnt, score_median, score_std"
    ]
   },
   {
@@ -463,7 +452,7 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "@jit_fun\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
     "def fast_minima(y):\n",
     "    minima = np.zeros(len(y))\n",
     "    \n",
@@ -482,14 +471,11 @@
     "    \n",
     "    return minima\n",
     "\n",
-    "@parallel_compiled_func(cpu_only = True)\n",
-    "def split(idx, hill_ptrs_range, hill_ptrs, int_data, hill_data, splits, hill_split_level, window):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
-    "        \n",
-    "    k = hill_ptrs_range[x]\n",
+    "@alphapept.performance.performance_function(compilation_mode=\"numba-multithread\")\n",
+    "def split(k, hill_ptrs, int_data, hill_data, splits, split_level, window):\n",
+    "# def split(idx, hill_ptrs_range, hill_ptrs, int_data, hill_data, splits, split_level, window):\n",
+    "\n",
+    "#     k = hill_ptrs_range[idx]\n",
     "        \n",
     "    start = hill_ptrs[k]\n",
     "    end = hill_ptrs[k + 1]\n",
@@ -529,12 +515,32 @@
     "            splits[k] = start+min_\n",
     "            break # Split only once per iteration\n",
     "            \n",
-    "def split_hills(hill_ptrs, hill_data, int_data, hill_split_level, window):\n",
-    "    splits = np.zeros(len(hill_ptrs), dtype=cupy.int32)\n",
+    "def split_hills(hill_ptrs, hill_data, int_data, split_level, window):\n",
+    "      \n",
+    "    splits = np.zeros(len(int_data), dtype=np.int32)\n",
     "    to_check = np.arange(len(hill_ptrs)-1)\n",
     "    \n",
     "    while len(to_check) > 0:\n",
-    "        split(to_check, hill_ptrs, int_data, hill_data, splits, hill_split_level, window)\n",
+    "#         print(to_check)\n",
+    "#         print(len(hill_ptrs), len(to_check), len(splits))\n",
+    "#         print(\n",
+    "#             type(to_check),\n",
+    "#             type(hill_ptrs),\n",
+    "#             type(int_data),\n",
+    "#             type(hill_data),\n",
+    "#             type(splits),\n",
+    "#             type(split_level),\n",
+    "#             type(window)\n",
+    "#         )\n",
+    "#         print(\n",
+    "#             to_check.dtype,\n",
+    "#             hill_ptrs.dtype,\n",
+    "#             int_data.dtype,\n",
+    "#             hill_data.dtype,\n",
+    "#             splits.dtype,\n",
+    "#         )\n",
+    "#         split(range(len(to_check)), to_check, hill_ptrs, int_data, hill_data, splits, split_level, window)\n",
+    "        split(to_check, hill_ptrs, int_data, hill_data, splits, split_level, window)\n",
     "        splitpoints = splits.nonzero()[0]\n",
     "\n",
     "        to_check = np.zeros(len(hill_ptrs))\n",
@@ -564,14 +570,10 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "@parallel_compiled_func(cpu_only=True)\n",
-    "def check_large_hills(idx, large_peaks, hill_ptrs, hill_data, int_data, to_remove, hill_check_large = 40, hill_peak_factor = 2, window=1):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
-    "        \n",
-    "    k = large_peaks[x]\n",
+    "@alphapept.performance.performance_function(compilation_mode=\"numba-multithread\")\n",
+    "def check_large_hills(idx, large_peaks, hill_ptrs, hill_data, int_data, to_remove, large_peak = 40, hill_peak_factor = 2, window=1):\n",
+    "\n",
+    "    k = large_peaks[idx]\n",
     "\n",
     "    start = hill_ptrs[k]\n",
     "    end = hill_ptrs[k + 1]\n",
@@ -595,7 +597,7 @@
     "    max_ = np.max(int_)\n",
     "\n",
     "    if (max_ / int_smooth_[0] > hill_peak_factor) & (max_ / int_smooth_[-1] > hill_peak_factor):\n",
-    "        to_remove[x] = 0 \n",
+    "        to_remove[idx] = 0 \n",
     "    \n",
     "\n",
     "def filter_hills(hill_data, hill_ptrs, int_data, hill_check_large =40, window = 1):\n",
@@ -603,7 +605,7 @@
     "    large_peaks = np.where(np.diff(hill_ptrs)>=hill_check_large)[0]\n",
     "    \n",
     "    to_remove = np.ones(len(large_peaks), dtype=np.int32)\n",
-    "    check_large_hills(large_peaks, hill_ptrs, hill_data, int_data, to_remove, window)\n",
+    "    check_large_hills(range(len(large_peaks)), large_peaks, hill_ptrs, hill_data, int_data, to_remove, window)\n",
     "    \n",
     "    idx_ = np.ones(len(hill_data), dtype = np.int32)\n",
     "    keep = np.ones(len(hill_ptrs)-1, dtype = np.int32)\n",
@@ -660,19 +662,12 @@
    "source": [
     "#export\n",
     "\n",
-    "@parallel_compiled_func(cpu_only=True) #Only CPU optimized at this point\n",
-    "def hill_stats(idx, hill_range, hill_ptrs, hill_data, int_data, mass_data, rt_, rt_idx, stats, hill_nboot_max, hill_nboot):\n",
-    "    if idx == -1:\n",
-    "        k = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        k = idx\n",
-    "        \n",
-    "    x = hill_range[k]\n",
-    "        \n",
+    "@alphapept.performance.performance_function(compilation_mode=\"numba-multithread\")\n",
+    "def hill_stats(idx, hill_range, hill_ptrs, hill_data, int_data, mass_data, rt_, rt_idx, stats, hill_nboot_max, hill_nboot):        \n",
     "    np.random.seed(42)\n",
     "        \n",
-    "    start = hill_ptrs[x]\n",
-    "    end = hill_ptrs[x + 1]\n",
+    "    start = hill_ptrs[idx]\n",
+    "    end = hill_ptrs[idx + 1]\n",
     "\n",
     "    idx_ = hill_data[start:end]\n",
     "\n",
@@ -706,12 +701,12 @@
     "        delta += (average_mz - averages[i]) ** 2 #maybe easier?\n",
     "    delta_m = np.sqrt(delta / (hill_nboot - 1))\n",
     "    \n",
-    "    stats[x,0] = average_mz\n",
-    "    stats[x,1] = delta_m\n",
-    "    stats[x,2] = int_sum\n",
-    "    stats[x,3] = int_area\n",
-    "    stats[x,4] = rt_min\n",
-    "    stats[x,5] = rt_max\n",
+    "    stats[idx,0] = average_mz\n",
+    "    stats[idx,1] = delta_m\n",
+    "    stats[idx,2] = int_sum\n",
+    "    stats[idx,3] = int_area\n",
+    "    stats[idx,4] = rt_min\n",
+    "    stats[idx,5] = rt_max\n",
     "    \n",
     "def remove_duplicates(stats, hill_data, hill_ptrs):\n",
     "    dups = pd.DataFrame(stats).duplicated() #all duplicated hills\n",
@@ -742,11 +737,8 @@
     "    int_data = np.array(query_data['int_list_ms1'])\n",
     "    \n",
     "    stats = np.zeros((len(hill_ptrs)-1, 6)) #mz, delta, rt_min, rt_max, sum_max\n",
-    "    hill_stats(np.arange(len(hill_ptrs)-1), hill_ptrs, hill_data, int_data, mass_data, rt_, scan_idx, stats, hill_nboot_max, hill_nboot)\n",
-    "    \n",
-    "    # Remove duplicate hills\n",
-    "    hill_data, hill_ptrs, stats = remove_duplicates(stats, hill_data, hill_ptrs)\n",
-    "    \n",
+    "    hill_stats(range(len(hill_ptrs)-1), np.arange(len(hill_ptrs)-1), hill_ptrs, hill_data, int_data, mass_data, rt_, scan_idx, stats, hill_nboot_max, hill_nboot)\n",
+    "\n",
     "    # sort the stats\n",
     "    sortindex = np.argsort(stats[:,4]) #Sorted by rt_min\n",
     "    stats = stats[sortindex,:]\n",
@@ -780,14 +772,13 @@
    "source": [
     "#export\n",
     "from alphapept.constants import mass_dict\n",
-    "from numba import njit\n",
     "\n",
     "DELTA_M = mass_dict['delta_M']\n",
     "DELTA_S = mass_dict['delta_S']\n",
     "maximum_offset = DELTA_M + DELTA_S\n",
     "\n",
-    "@njit\n",
-    "def check_isotope_pattern(mass1, mass2, delta_mass1, delta_mass2, charge, iso_mass_range = 5):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def check_isotope_pattern(mass1, mass2, delta_mass1, delta_mass2, charge, mass_range = 5):\n",
     "    \"\"\"\n",
     "    Check if two masses could belong to the same isotope pattern\n",
     "    \"\"\"\n",
@@ -898,7 +889,7 @@
    "source": [
     "#export\n",
     "\n",
-    "@njit\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
     "def correlate(scans_, scans_2, int_, int_2):\n",
     "    \n",
     "    min_one, max_one = scans_[0], scans_[-1]\n",
@@ -943,8 +934,8 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "@njit\n",
-    "def extract_edge(stats, idxs_upper, runner, max_index, maximum_offset,  iso_charge_min = 1, iso_charge_max = 6, iso_mass_range=5):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def extract_edge(stats, idxs_upper, runner, max_index, maximum_offset,  min_charge = 1, max_charge = 6, mass_range=5):\n",
     "    edges = []\n",
     "\n",
     "    mass1 = stats[runner, 0]\n",
@@ -961,14 +952,10 @@
     "\n",
     "    return edges\n",
     "\n",
-    "@parallel_compiled_func(cpu_only=True) #Only CPU optimized at this point\n",
+    "@alphapept.performance.performance_function(compilation_mode=\"numba-multithread\")\n",
     "def edge_correlation(idx, to_keep, sortindex_, pre_edges, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
     "        \n",
-    "    edge = pre_edges[x,:]\n",
+    "    edge = pre_edges[idx,:]\n",
     "    \n",
     "    y = sortindex_[edge[0]]\n",
     "    start = hill_ptrs[y]\n",
@@ -985,7 +972,7 @@
     "    scans_2 = scan_idx[idx_2]\n",
     "\n",
     "    if correlate(scans_, scans_2, int_, int_2) > cc_cutoff:\n",
-    "        to_keep[x] = 1"
+    "        to_keep[idx] = 1"
    ]
   },
   {
@@ -1006,7 +993,7 @@
     "\n",
     "    to_keep = np.zeros(len(pre_edges), dtype='int')\n",
     "    pre_edges = np.array(pre_edges)\n",
-    "    edge_correlation(to_keep, sortindex_, pre_edges, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff)\n",
+    "    edge_correlation(range(len(to_keep)), to_keep, sortindex_, pre_edges, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff)\n",
     "    edges = pre_edges[to_keep.nonzero()]\n",
     "\n",
     "    G2 = nx.Graph()\n",
@@ -1046,8 +1033,8 @@
    "source": [
     "#export\n",
     "\n",
-    "@njit\n",
-    "def check_isotope_pattern_directed(mass1, mass2, delta_mass1, delta_mass2, charge, index, iso_mass_range):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def check_isotope_pattern_directed(mass1, mass2, delta_mass1, delta_mass2, charge, index, mass_range):\n",
     "    \"\"\"\n",
     "    Check if two masses could belong to the same isotope pattern\n",
     "\n",
@@ -1061,8 +1048,8 @@
     "    return left_side <= right_side\n",
     "\n",
     "\n",
-    "@njit\n",
-    "def grow(trail, seed, direction, relative_pos, index, stats, pattern, charge, iso_mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def grow(trail, seed, direction, relative_pos, index, stats, pattern, charge, mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):\n",
     "    \"\"\"\n",
     "    Grows isotope pattern based on a seed and direction\n",
     "\n",
@@ -1122,8 +1109,8 @@
     "\n",
     "    return trail\n",
     "\n",
-    "@njit\n",
-    "def grow_trail(seed, pattern, stats, charge, iso_mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def grow_trail(seed, pattern, stats, charge, mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):\n",
     "    \"\"\"\n",
     "    Wrapper to grow an isotope pattern to the left and right side\n",
     "    \"\"\"\n",
@@ -1136,8 +1123,8 @@
     "    return trail\n",
     "\n",
     "\n",
-    "@njit\n",
-    "def get_trails(seed, pattern, stats, charge_range, iso_mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def get_trails(seed, pattern, stats, charge_range, mass_range, sortindex_, hill_ptrs, hill_data, int_data, scan_idx, cc_cutoff):\n",
     "    \"\"\"\n",
     "    Wrapper to extract trails for a given charge range\n",
     "    \"\"\"\n",
@@ -1196,24 +1183,8 @@
    "source": [
     "#export\n",
     "\n",
-    "@njit\n",
-    "def get_local_minima(y):\n",
-    "    \"\"\"\n",
-    "    Function to return all local minima of a array\n",
-    "    \"\"\"\n",
-    "    minima = List()\n",
-    "    for i in range(1, len(y) - 1):\n",
-    "        if is_local_minima(y, i):\n",
-    "            minima.append(i)\n",
-    "    return minima\n",
-    "\n",
-    "\n",
-    "@njit\n",
-    "def is_local_minima(y, i):\n",
-    "    return (y[i - 1] > y[i]) & (y[i + 1] > y[i])\n",
-    "\n",
-    "@njit\n",
-    "def get_minpos(y, iso_split_level=1.3):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def get_minpos(y, split=5):\n",
     "    \"\"\"\n",
     "    Function to get a list of minima in a trace.\n",
     "    A minimum is returned if the ratio of lower of the surrounding maxima to the minimum is larger than the splitting factor.\n",
@@ -1235,9 +1206,25 @@
     "\n",
     "    return minima_list\n",
     "\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def get_local_minima(y):\n",
+    "    \"\"\"\n",
+    "    Function to return all local minima of a array\n",
+    "    \"\"\"\n",
+    "    minima = List()\n",
+    "    for i in range(1, len(y) - 1):\n",
+    "        if is_local_minima(y, i):\n",
+    "            minima.append(i)\n",
+    "    return minima\n",
+    "\n",
     "\n",
-    "@njit\n",
-    "def truncate(array, intensity_profile, seedpos, iso_split_level):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def is_local_minima(y, i):\n",
+    "    return (y[i - 1] > y[i]) & (y[i + 1] > y[i])\n",
+    "\n",
+    "\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def truncate(array, intensity_profile, seedpos):\n",
     "    \"\"\"\n",
     "    Function to truncate an intensity profile around its seedposition\n",
     "    \"\"\"\n",
@@ -1290,7 +1277,7 @@
     "from alphapept.chem import mass_to_dist\n",
     "from alphapept.constants import averagine_aa, isotopes\n",
     "\n",
-    "@njit\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
     "def check_averagine(stats, pattern, charge, averagine_aa, isotopes):\n",
     "\n",
     "    masses, intensity = pattern_to_mz(stats, pattern, charge)\n",
@@ -1304,7 +1291,7 @@
     "\n",
     "    return cosine_averagine(int_one, int_two, spec_one, spec_two)\n",
     "\n",
-    "@njit\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
     "def pattern_to_mz(stats, pattern, charge):\n",
     "    \"\"\"\n",
     "    Function to calculate masses and intensities from pattern for a given charge\n",
@@ -1324,7 +1311,7 @@
     "\n",
     "    return masses, intensity\n",
     "\n",
-    "@njit\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
     "def cosine_averagine(int_one, int_two, spec_one, spec_two):\n",
     "\n",
     "    min_one, max_one = spec_one[0], spec_one[-1]\n",
@@ -1347,7 +1334,7 @@
     "\n",
     "\n",
     "\n",
-    "@njit\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
     "def int_list_to_array(numba_list):\n",
     "    \"\"\"\n",
     "    Numba compatbilte function to convert a numba list with integers to a numpy array\n",
@@ -1362,7 +1349,7 @@
     "\n",
     "M_PROTON = mass_dict['Proton']\n",
     "\n",
-    "@njit\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
     "def mz_to_mass(mz, charge):\n",
     "    \"\"\"\n",
     "    Function to calculate the mass from a mz value.\n",
@@ -1372,7 +1359,79 @@
     "\n",
     "    mass = mz * charge - charge * M_PROTON\n",
     "\n",
-    "    return mass"
+    "    return mass\n",
+    "\n",
+    "\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def get_minpos(y, split=5):\n",
+    "    \"\"\"\n",
+    "    Function to get a list of minima in a trace.\n",
+    "    A minimum is returned if the ratio of lower of the surrounding maxima to the minimum is larger than the splitting factor.\n",
+    "    \"\"\"\n",
+    "    minima = get_local_minima(y)\n",
+    "    minima_list = List()\n",
+    "\n",
+    "    for minpos in minima:\n",
+    "\n",
+    "        minval = y[minpos]\n",
+    "        left_side = y[:minpos]\n",
+    "        right_side = y[minpos:]\n",
+    "\n",
+    "        left_max = np.max(left_side)\n",
+    "        right_max = np.max(right_side)\n",
+    "\n",
+    "        minimum_max = np.min(np.array((left_max, right_max)))\n",
+    "\n",
+    "        if minimum_max / minval > split:\n",
+    "            minima_list.append(minpos)\n",
+    "\n",
+    "    return minima_list\n",
+    "\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def get_local_minima(y):\n",
+    "    \"\"\"\n",
+    "    Function to return all local minima of a array\n",
+    "    \"\"\"\n",
+    "    minima = List()\n",
+    "    for i in range(1, len(y) - 1):\n",
+    "        if is_local_minima(y, i):\n",
+    "            minima.append(i)\n",
+    "    return minima\n",
+    "\n",
+    "\n",
+    "\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def is_local_minima(y, i):\n",
+    "    return (y[i - 1] > y[i]) & (y[i + 1] > y[i])\n",
+    "\n",
+    "\n",
+    "\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def truncate(array, intensity_profile, seedpos):\n",
+    "    \"\"\"\n",
+    "    Function to truncate an intensity profile around its seedposition\n",
+    "    \"\"\"\n",
+    "    minima = int_list_to_array(get_minpos(intensity_profile))\n",
+    "\n",
+    "    if len(minima) > 0:\n",
+    "        left_minima = minima[minima < seedpos]\n",
+    "        right_minima = minima[minima > seedpos]\n",
+    "\n",
+    "        # If the minimum is smaller than the seed\n",
+    "        if len(left_minima) > 0:\n",
+    "            minpos = left_minima[-1]\n",
+    "        else:\n",
+    "            minpos = 0\n",
+    "\n",
+    "        if len(right_minima) > 0:\n",
+    "            maxpos = right_minima[0]\n",
+    "        else:\n",
+    "            maxpos = len(array)\n",
+    "\n",
+    "        array = array[minpos:maxpos+1]\n",
+    "        intensity_profile = intensity_profile[minpos:maxpos+1]\n",
+    "\n",
+    "    return array, intensity_profile"
    ]
   },
   {
@@ -1452,8 +1511,8 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "@njit\n",
-    "def isolate_isotope_pattern(pre_pattern, hill_ptrs, hill_data, int_data, scan_idx, stats, sortindex_, iso_mass_range, charge_range, averagine_aa, isotopes, iso_n_seeds, cc_cutoff, iso_split_level):\n",
+    "@alphapept.performance.compile_function(compilation_mode=\"numba\")\n",
+    "def isolate_isotope_pattern(pre_pattern, hill_ptrs, hill_data, int_data, scan_idx, stats, sortindex_, mass_range, charge_range, averagine_aa, isotopes, seed_masses, cc_cutoff):\n",
     "    \"\"\"\n",
     "    Isolate isotope patterns\n",
     "    \"\"\"\n",
@@ -1646,21 +1705,15 @@
    "outputs": [],
    "source": [
     "#export\n",
-    "@parallel_compiled_func(cpu_only=True)\n",
+    "@alphapept.performance.performance_function(compilation_mode=\"numba-multithread\")\n",
     "def report_(idx, isotope_charges, isotope_patterns, iso_idx, stats, sortindex_, hill_ptrs, hill_data, int_data, rt_, rt_idx, results):\n",
-    "    if idx == -1:\n",
-    "        x = alphapept.speed.cuda.grid(1)\n",
-    "    else:\n",
-    "        x = idx\n",
     "        \n",
-    "    i = x\n",
-    "        \n",
-    "    pattern = isotope_patterns[iso_idx[i]:iso_idx[i+1]]\n",
+    "    pattern = isotope_patterns[iso_idx[idx]:iso_idx[idx+1]]\n",
     "    isotope_data = stats[pattern]\n",
     "\n",
     "    mz = np.min(isotope_data[:, 0])\n",
     "    mz_std = np.mean(isotope_data[:, 1])\n",
-    "    charge = isotope_charges[i]\n",
+    "    charge = isotope_charges[idx]\n",
     "    mass = mz_to_mass(mz, charge)\n",
     "    int_max_idx = np.argmax(isotope_data[:, 2])\n",
     "    mz_most_abundant = isotope_data[:, 0][int_max_idx]\n",
@@ -1701,10 +1754,6 @@
     "        \n",
     "        trace_sum += interpolation\n",
     "        \n",
-    "\n",
-    "        #plt.plot(rt_range, interpolation)\n",
-    "\n",
-    "            \n",
     "    rt_apex_idx = trace_sum.argmax()\n",
     "    rt_apex = rt_range[rt_apex_idx]\n",
     "    \n",
@@ -1718,7 +1767,6 @@
     "    right_apex = np.abs(trace[rt_apex_idx:]-half_max).argmin()+rt_apex_idx\n",
     "    \n",
     "    int_apex = trace_sum[rt_apex_idx]\n",
-    "\n",
     "    fwhm = rt_range[right_apex] - rt_range[left_apex]\n",
     "    \n",
     "    n_isotopes = len(pattern)\n",
@@ -1746,15 +1794,10 @@
     "    rt_start = rt_range[rt_min_idx]\n",
     "    rt_end = rt_range[rt_max_idx]\n",
     "    \n",
-    "    \n",
-    "    \n",
-    "    \n",
     "    int_area = np.trapz(trace_sum[rt_min_idx:rt_max_idx], rt_range[rt_min_idx:rt_max_idx])\n",
     "    int_sum = trace_sum.sum()\n",
     "        \n",
-    "    results[i,:] = np.array([mz, mz_std, mz_most_abundant, charge, rt_start, rt_apex, rt_end, fwhm, n_isotopes, mass, int_apex, int_area, int_sum])\n",
-    "           \n",
-    "    "
+    "    results[idx,:] = np.array([mz, mz_std, mz_most_abundant, charge, rt_start, rt_apex, rt_end, fwhm, n_isotopes, mass, int_apex, int_area, int_sum])"
    ]
   },
   {
@@ -1776,7 +1819,7 @@
     "\n",
     "    results = np.zeros((len(isotope_charges), 13))\n",
     "\n",
-    "    report_(isotope_charges, isotope_patterns, iso_idx, stats, sortindex_, hill_ptrs, hill_data, int_data, rt_, rt_idx, results)\n",
+    "    report_(range(len(isotope_charges)), isotope_charges, isotope_patterns, iso_idx, stats, sortindex_, hill_ptrs, hill_data, int_data, rt_, rt_idx, results)\n",
     "\n",
     "    df = pd.DataFrame(results, columns = ['mz','mz_std','mz_most_abundant','charge','rt_start','rt_apex','rt_end','fwhm','n_isotopes','mass','int_apex','int_area', 'int_sum'])\n",
     "\n",
@@ -2146,12 +2189,15 @@
     "                    iso_corr_min = f_settings['iso_corr_min']\n",
     "                    \n",
     "                    logging.info('Feature finding on {}'.format(file_name))\n",
-    "                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap = max_gap, centroid_tol = centroid_tol)\n",
+    "                    \n",
+    "                    logging.info(f'Hill extraction with ppm_tol {ppm_tol} and max_gap {max_gap}')\n",
+    "                    \n",
+    "                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap, ppm_tol)\n",
     "                    logging.info(f'Number of hills {len(hill_ptrs):,}, len = {np.mean(path_node_cnt):.2f}')\n",
     "                    \n",
     "                    logging.info(f'Repeating hill extraction with centroid_tol {score_median+score_std*3:.2f}')\n",
     "                    \n",
-    "                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap = max_gap, centroid_tol = score_median+score_std*3)\n",
+    "                    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = extract_hills(query_data, max_gap, score_median+score_std*3)\n",
     "                    logging.info(f'Number of hills {len(hill_ptrs):,}, len = {np.mean(path_node_cnt):.2f}')\n",
     "\n",
     "                    int_data = np.array(query_data['int_list_ms1'])\n",
@@ -2367,6 +2413,7 @@
       "Converted 02_io.ipynb.\n",
       "Converted 03_fasta.ipynb.\n",
       "Converted 04_feature_finding.ipynb.\n",
+      "Converted 04_feature_finding2.ipynb.\n",
       "Converted 05_search.ipynb.\n",
       "Converted 06_score.ipynb.\n",
       "Converted 07_recalibration.ipynb.\n",
@@ -2375,9 +2422,14 @@
       "Converted 10_constants.ipynb.\n",
       "Converted 11_interface.ipynb.\n",
       "Converted 12_speed.ipynb.\n",
-      "Converted 13_export.ipynb.\n",
       "Converted index.ipynb.\n",
-      "Converted XX_file_formats.ipynb.\n"
+      "Converted minimal_example.ipynb.\n",
+      "Converted minimal_example_2.ipynb.\n",
+      "Converted minimal_example_3.ipynb.\n",
+      "Converted test.ipynb.\n",
+      "Converted Untitled.ipynb.\n",
+      "Converted Untitled1.ipynb.\n",
+      "Converted Untitled2.ipynb.\n"
      ]
     }
    ],
@@ -2390,9 +2442,9 @@
  ],
  "metadata": {
   "kernelspec": {
-   "display_name": "Python [conda env:.conda-alphapept] *",
+   "display_name": "Python 3",
    "language": "python",
-   "name": "conda-env-.conda-alphapept-py"
+   "name": "python3"
   }
  },
  "nbformat": 4,
diff --git a/nbs/11_interface.ipynb b/nbs/11_interface.ipynb
index aafab101..ace609ea 100644
--- a/nbs/11_interface.ipynb
+++ b/nbs/11_interface.ipynb
@@ -73,7 +73,6 @@
     "import psutil\n",
     "\n",
     "def parallel_execute(settings, step, callback=None):\n",
-    "    \n",
     "    \"\"\"\n",
     "    Generic function to parallel execute worklow steps on a file basis\n",
     "    \n",
diff --git a/nbs/12_speed.ipynb b/nbs/12_speed.ipynb
index ff905036..3629cf14 100644
--- a/nbs/12_speed.ipynb
+++ b/nbs/12_speed.ipynb
@@ -26,33 +26,84 @@
    "outputs": [],
    "source": [
     "#export \n",
-    "\n",
-    "# A decorator for writing GPU/CPU agnostic code\n",
     "import multiprocessing\n",
     "import threading\n",
     "import functools\n",
     "import math\n",
-    "import numba as numba_\n",
-    "numba = numba_\n",
     "import numpy as np\n",
-    "from numba import cuda as cuda_\n",
-    "cuda = cuda_\n",
     "from numba import njit\n",
+    "import psutil\n",
+    "from multiprocessing import Pool\n",
+    "import os\n",
     "import logging\n",
     "\n",
+    "import numpy as numpy_\n",
+    "cupy = numpy_\n",
+    "from numba import cuda as cuda_\n",
+    "cuda = cuda_\n",
+    "import numba as numba_\n",
+    "numba = numba_\n",
+    "\n",
+    "# We use jit_fun and jit_fun_gpu.\n",
+    "# This is to be able too distinguish GPU device functions and numba optimized functions in a GPU setting\n",
+    "\n",
+    "jit_fun = None\n",
+    "jit_fun_gpu = None\n",
+    "speed_mode = None\n",
+    "\n",
+    "\n",
     "try:\n",
-    "    import cupy\n",
-    "    jit_fun = cuda.jit(device=True) #Device Function\n",
+    "    import cupy as cupy_\n",
     "except ModuleNotFoundError:\n",
-    "    import numpy as cupy\n",
-    "    jit_fun = njit\n",
+    "    cupy_ = None\n",
+    "\n",
+    "def dummy_decorator(func):\n",
+    "    \"\"\"\n",
+    "    Dummy decorator that does nothing\n",
+    "    \"\"\"\n",
+    "    return func\n",
+    "\n",
+    "def set_speed_mode(mode):\n",
+    "    \"\"\"\n",
+    "    Function to change \n",
+    "    \n",
+    "    \"\"\"\n",
+    "    global jit_fun\n",
+    "    global jit_fun_gpu\n",
+    "    global speed_mode\n",
+    "    global cupy\n",
+    "    \n",
+    "    speed_mode = mode\n",
+    "    \n",
+    "    if mode == 'python':\n",
+    "        jit_fun = dummy_decorator\n",
+    "        jit_fun_gpu = dummy_decorator\n",
+    "        cupy = numpy_\n",
+    "    elif mode == 'numba':\n",
+    "        jit_fun = njit\n",
+    "        jit_fun_gpu = njit\n",
+    "        cupy = numpy_\n",
+    "    elif mode == 'numba_gpu':\n",
+    "        jit_fun = njit\n",
+    "        jit_fun_gpu = cuda.jit(device=True)\n",
+    "        if cupy_ is not None:\n",
+    "            cupy = cupy_\n",
+    "        else:\n",
+    "            raise ModuleNotFoundError('Cupy not installed.')\n",
+    "    else:\n",
+    "        raise NotImplementedError(mode)\n",
+    "        \n",
+    "        \n",
+    "if cupy_ is not None:\n",
+    "    set_speed_mode('numba_gpu')\n",
+    "else:\n",
+    "    set_speed_mode('numba')\n",
     "    \n",
     "@numba.njit\n",
     "def grid_1d(x): return -1\n",
     "@numba.njit\n",
     "def grid_2d(x): return -1, -1\n",
     "\n",
-    "\n",
     "def set_cuda_grid(dimensions=0):\n",
     "    global cuda\n",
     "    if dimensions == 0:\n",
@@ -64,15 +115,54 @@
     "    if dimensions == 2:\n",
     "        cuda = numba_\n",
     "        cuda.grid = grid_2d\n",
-    "      \n",
+    "        \n",
+    "        \n",
+    "def numba_threaded(_func=None, *, cpu_threads=0):\n",
+    "    if cpu_threads <= 0:\n",
+    "        cpu_threads = multiprocessing.cpu_count()\n",
+    "        \n",
+    "    def parallel_compiled_func_inner(func):\n",
+    "        if speed_mode == 'python':\n",
+    "            numba_func = func\n",
+    "        else:\n",
+    "            numba_func = numba.njit(nogil=True)(func)\n",
+    "\n",
+    "        def numba_func_parallel(thread, iterable, *args):\n",
+    "            for i in range(thread, len(iterable), cpu_threads):\n",
+    "                numba_func(i, iterable, *args)\n",
+    "\n",
+    "        if speed_mode == 'python':\n",
+    "            numba_func_parallel = numba_func_parallel\n",
+    "        else:\n",
+    "            numba_func_parallel = numba.njit(nogil=True)(numba_func_parallel)\n",
+    "\n",
+    "        def wrapper(iterable, *args):\n",
+    "            threads = []\n",
+    "            for thread_id in range(cpu_threads):\n",
+    "                t = threading.Thread(\n",
+    "                    target=numba_func_parallel,\n",
+    "                    args=(thread_id, iterable, *args)\n",
+    "                )\n",
+    "                t.start()\n",
+    "                threads.append(t)\n",
+    "            for t in threads:\n",
+    "                t.join()\n",
+    "                del t\n",
+    "        return functools.wraps(func)(wrapper)\n",
+    "    \n",
+    "    if _func is None:\n",
+    "        return parallel_compiled_func_inner\n",
+    "    else:\n",
+    "        return parallel_compiled_func_inner(_func)\n",
+    "    \n",
+    "     \n",
     "def parallel_compiled_func(\n",
     "    _func=None,\n",
     "    *,\n",
     "    cpu_threads=None,\n",
     "    dimensions=1,\n",
-    "    cpu_only = False,\n",
     "):\n",
-    "    set_cuda_grid()\n",
+    "    #set_cuda_grid()\n",
     "    if dimensions not in (1, 2):\n",
     "        raise ValueError(\"Only 1D and 2D are supported\")\n",
     "\n",
@@ -80,8 +170,8 @@
     "        use_gpu = False\n",
     "    else:\n",
     "        try:\n",
-    "            cuda.get_current_device()\n",
-    "        except cuda.CudaSupportError:\n",
+    "            cuda_.get_current_device()\n",
+    "        except cuda_.CudaSupportError:\n",
     "            use_gpu = False\n",
     "            cpu_threads = 0\n",
     "        else:\n",
@@ -91,12 +181,20 @@
     "        except ModuleNotFoundError:\n",
     "            use_gpu = False\n",
     "            cpu_threads = 0\n",
-    "    \n",
-    "    if cpu_only:\n",
+    "            \n",
+    "    if cpu_threads is None:\n",
+    "        cpu_threads = multiprocessing.cpu_count()\n",
+    "        \n",
+    "    if cpu_threads <= 0:\n",
+    "        cpu_threads = multiprocessing.cpu_count()\n",
+    "\n",
+    "    if speed_mode == 'numba_gpu':\n",
+    "        use_gpu = True\n",
+    "    elif speed_mode == 'numba':\n",
     "        use_gpu = False\n",
-    "        if cpu_threads is None: \n",
-    "            cpu_threads = 0\n",
-    "    \n",
+    "    elif speed_mode == 'python':\n",
+    "        use_gpu = False\n",
+    "                \n",
     "    if use_gpu:\n",
     "        set_cuda_grid()\n",
     "        def parallel_compiled_func_inner(func):\n",
@@ -133,7 +231,12 @@
     "        if cpu_threads <= 0:\n",
     "            cpu_threads = multiprocessing.cpu_count()\n",
     "        def parallel_compiled_func_inner(func):\n",
-    "            numba_func = numba.njit(nogil=True)(func)\n",
+    "            \n",
+    "            if speed_mode == 'python':\n",
+    "                numba_func = func\n",
+    "            else:\n",
+    "                numba_func = numba.njit(nogil=True)(func)\n",
+    "\n",
     "            if dimensions == 1:\n",
     "                def numba_func_parallel(\n",
     "                    thread,\n",
@@ -159,7 +262,12 @@
     "                    ):\n",
     "                        for j in range(iterable_2d.shape[1]):\n",
     "                            numba_func(i, j, iterable_2d, *args)\n",
-    "            numba_func_parallel = numba.njit(nogil=True)(numba_func_parallel)\n",
+    "                            \n",
+    "            if speed_mode == 'python':\n",
+    "                numba_func_parallel = numba_func_parallel\n",
+    "            else:\n",
+    "                numba_func_parallel = numba.njit(nogil=True)(numba_func_parallel)\n",
+    "\n",
     "            def wrapper(iterable, *args):\n",
     "                threads = []\n",
     "                for thread_id in range(cpu_threads):\n",
@@ -178,10 +286,6 @@
     "    else:\n",
     "        return parallel_compiled_func_inner(_func)\n",
     "    \n",
-    "import psutil\n",
-    "from multiprocessing import Pool\n",
-    "import os\n",
-    "\n",
     "def set_max_process(a):\n",
     "    max_processes = psutil.cpu_count()\n",
     "    new_max = min(a, max_processes)\n",
@@ -214,6 +318,7 @@
       "Converted 02_io.ipynb.\n",
       "Converted 03_fasta.ipynb.\n",
       "Converted 04_feature_finding.ipynb.\n",
+      "Converted 04_feature_finding2.ipynb.\n",
       "Converted 05_search.ipynb.\n",
       "Converted 06_score.ipynb.\n",
       "Converted 07_recalibration.ipynb.\n",
@@ -222,7 +327,14 @@
       "Converted 10_constants.ipynb.\n",
       "Converted 11_interface.ipynb.\n",
       "Converted 12_speed.ipynb.\n",
-      "Converted index.ipynb.\n"
+      "Converted index.ipynb.\n",
+      "Converted minimal_example.ipynb.\n",
+      "Converted minimal_example_2.ipynb.\n",
+      "Converted minimal_example_3.ipynb.\n",
+      "Converted test.ipynb.\n",
+      "Converted Untitled.ipynb.\n",
+      "Converted Untitled1.ipynb.\n",
+      "Converted Untitled2.ipynb.\n"
      ]
     }
    ],
diff --git a/nbs/13_performance.ipynb b/nbs/13_performance.ipynb
new file mode 100644
index 00000000..c984a3d8
--- /dev/null
+++ b/nbs/13_performance.ipynb
@@ -0,0 +1,740 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "beneficial-fields",
+   "metadata": {},
+   "source": [
+    "# Performance"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b5223bb0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# default_exp performance"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ffefe0cd",
+   "metadata": {},
+   "source": [
+    "AlphaPept deals with high-throughput data. As this can be computationally intensive, we try to make all functions as performant as possible. To do so, we rely on two principles:\n",
+    "* **Compilation**\n",
+    "* **Parallelization**\n",
+    "\n",
+    "A first step of **compilation** can be achieved by using NumPy arrays which are already heavily c-optimized. Net we consider three kinds of compilation:\n",
+    "* **Python** This allows to use no compilation\n",
+    "* **Numba** This allows to use just-in-time (JIT) compilation.\n",
+    "* **Cuda** This allows compilation on the GPU.\n",
+    "\n",
+    "All of these compilation approaches can be combined with **parallelization** approaches. We consider the following possibilities:\n",
+    "* **No parallelization** Not all functionality can be parallelized.\n",
+    "* **Multithreading** This is only performant when Python's global interpreter lock (GIL) is released or when mostly using input-/output (IO) functions.\n",
+    "* **GPU** This is only available if an NVIDIA GPU is available and properly configured.\n",
+    "\n",
+    "Note that not all compilation approaches can sensibly be combined with all parallelization approaches."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9e256be3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#export \n",
+    "\n",
+    "COMPILATION_MODE_OPTIONS = [\n",
+    "    \"python\",\n",
+    "    \"python-multithread\",\n",
+    "    \"numba\",\n",
+    "    \"numba-multithread\",\n",
+    "    \"cuda\", # Cuda is always multithreaded\n",
+    "]"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "575b0c6e",
+   "metadata": {},
+   "source": [
+    "Next we import all libraries, taking into account that not every machine has a GPU (with NVidia cuda cores) available:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "cbb507f0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#export \n",
+    "\n",
+    "import functools\n",
+    "import math\n",
+    "import os\n",
+    "import logging\n",
+    "import psutil\n",
+    "import ast\n",
+    "\n",
+    "# Parallelization\n",
+    "import multiprocessing\n",
+    "import threading\n",
+    "\n",
+    "# Compilation\n",
+    "import numpy as np\n",
+    "import numba\n",
+    "from numba import cuda\n",
+    "try:\n",
+    "    import cupy\n",
+    "    cuda.get_current_device()\n",
+    "    __GPU_AVAILABLE = True\n",
+    "except ModuleNotFoundError:\n",
+    "    __GPU_AVAILABLE = False\n",
+    "    cupy = None\n",
+    "    logging.info(\"Cupy is not available\")\n",
+    "except cuda.CudaSupportError:\n",
+    "    __GPU_AVAILABLE = False\n",
+    "    logging.info(\"Cuda device is not available\")\n",
+    "    \n",
+    "def is_valid_compilation_mode(compilation_mode: str) -> None:\n",
+    "    \"\"\"Check if the provided string is a valid compilation mode.\n",
+    "    \n",
+    "    Parameters\n",
+    "    ----------\n",
+    "    compilation_mode : str\n",
+    "        The compilation mode to verify.\n",
+    "\n",
+    "    Raises\n",
+    "    ------\n",
+    "    ModuleNotFoundError\n",
+    "        When trying to use an unavailable GPU.\n",
+    "    NotImplementedError\n",
+    "        When the compilation mode is not valid.\n",
+    "    \"\"\"\n",
+    "    if compilation_mode.startswith(\"cuda\") and not __GPU_AVAILABLE:\n",
+    "        raise ModuleNotFoundError('Cuda functions are not available.')\n",
+    "    if compilation_mode not in COMPILATION_MODE_OPTIONS:\n",
+    "        raise NotImplementedError(\n",
+    "            f\"Compilation mode '{compilation_mode}' is not available, \"\n",
+    "            \"see COMPILATION_MODE_OPTIONS for available options.\"\n",
+    "        )"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "fc02a364",
+   "metadata": {},
+   "source": [
+    "By default, we will use `cuda` if it is available. If not, `numba-multithread` will be used as default."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "cb9fa1a4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#export\n",
+    "\n",
+    "if __GPU_AVAILABLE:\n",
+    "    COMPILATION_MODE = \"cuda\"\n",
+    "else:\n",
+    "    COMPILATION_MODE = \"numba-multithread\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "68eae564",
+   "metadata": {},
+   "source": [
+    "To consistently use multiple threads or processes, we can set a global MAX_WORKER_COUNT parameter."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8d131329",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#export \n",
+    "\n",
+    "MAX_WORKER_COUNT = psutil.cpu_count()\n",
+    "\n",
+    "def set_worker_count(worker_count: int = 1, set_global: bool = True) -> int:\n",
+    "    \"\"\"Parse and set the (global) number of threads.\n",
+    "\n",
+    "    Parameters\n",
+    "    ----------\n",
+    "    worker_count : int\n",
+    "        The number of workers.\n",
+    "        If larger than available cores, it is trimmed to the available maximum.\n",
+    "        If 0, it is set to the maximum cores available.\n",
+    "        If negative, it indicates how many cores NOT to use.\n",
+    "        Default is 1\n",
+    "    set_global : bool\n",
+    "        If False, the number of workers is only parsed to a valid value.\n",
+    "        If True, the number of workers is saved as a global variable.\n",
+    "        Default is True.\n",
+    "\n",
+    "    Returns\n",
+    "    -------\n",
+    "    : int\n",
+    "        The parsed worker_count.\n",
+    "    \"\"\"\n",
+    "    max_cpu_count = psutil.cpu_count()\n",
+    "    if worker_count > max_cpu_count:\n",
+    "        worker_count = max_cpu_count\n",
+    "    else:\n",
+    "        while worker_count <= 0:\n",
+    "            worker_count += max_cpu_count\n",
+    "    if set_global:\n",
+    "        global MAX_WORKER_COUNT\n",
+    "        MAX_WORKER_COUNT = worker_count\n",
+    "    return worker_count"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "10a3d21f",
+   "metadata": {},
+   "source": [
+    "Compiled functions are intended to be very fast. However, they do not have the same flexibility as pure Python functions. In general, we recommend to use staticly defined compilation functions for optimal performance. We provide the option to define a default compilation mode for decorated functions, while also allowing to define the compilation mode for each individual function.\n",
+    "\n",
+    "**NOTE**: Compiled functions are by default expected to be performed on a single thread. Thus, 'cuda' funtions are always assumed to be device functions which makes them callable from within the GPU, unless explicitly stated otherwise. Similarly, 'numba' functions are always assumed to bo 'nopython' and 'nogil'.\n",
+    "\n",
+    "**NOTE** If the global compilation mode is set to Python, all decorators default to python, even if a specific compilation_mode is provided.\n",
+    "\n",
+    "In addition, we allow to enable dynamic compilation, meaning the compilation mode of functions can be changed at runtime. Do note that this comes at the cost of some performance, as compilation needs to be done at runtime as well. Moreover, functions that are defined with dynamic compilation can not be called from within other compiled functions (with the exception of 'python' compilation, which means no compilation is actually performed).\n",
+    "\n",
+    "**NOTE**: Dynamic compilation must be enabled before functions are decorated to take effect at runtime, otherwise they are statically compiled with the current settings at the time they are defined! Alternatively, statically compiled functions of a an 'imported_module' can reloaded (and thus statically be recompiled) with the commands:\n",
+    "```\n",
+    "import importlib\n",
+    "importlib.reload(imported_module)\n",
+    "```"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1d849370",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#export \n",
+    "\n",
+    "DYNAMIC_COMPILATION_ENABLED = False\n",
+    "    \n",
+    "def set_compilation_mode(\n",
+    "    compilation_mode: str = None,\n",
+    "    enable_dynamic_compilation: bool = None,\n",
+    ") -> None:\n",
+    "    \"\"\"Set the global compilation mode to use.\n",
+    "    \n",
+    "    Parameters\n",
+    "    ----------\n",
+    "    compilation_mode : str\n",
+    "        The compilation mode to use.\n",
+    "        Will be checked with `is_valid_compilation_mode`.\n",
+    "        Default is None\n",
+    "    enable_dynamic_compilation : bool\n",
+    "        Enable dynamic compilation.\n",
+    "        If enabled, code will generally be slower and no other functions can\n",
+    "        be called from within a compiled function anymore, as they are compiled at runtime.\n",
+    "        WARNING: Enabling this is strongly disadvised in almost all cases!\n",
+    "        Default is None.\n",
+    "    \"\"\"\n",
+    "    if enable_dynamic_compilation is not None:\n",
+    "        global DYNAMIC_COMPILATION_ENABLED\n",
+    "        DYNAMIC_COMPILATION_ENABLED = enable_dynamic_compilation\n",
+    "    if compilation_mode is not None:\n",
+    "        is_valid_compilation_mode(compilation_mode)\n",
+    "        global COMPILATION_MODE\n",
+    "        COMPILATION_MODE = compilation_mode\n",
+    "    \n",
+    "\n",
+    "def compile_function(\n",
+    "    _func: callable = None,\n",
+    "    *,\n",
+    "    compilation_mode: str = None,\n",
+    "    **decorator_kwargs,\n",
+    ") -> callable:\n",
+    "    \"\"\"A decorator to compile a given function.\n",
+    "    \n",
+    "    Numba functions are by default set to use `nogil=True` and `nopython=True`,\n",
+    "    unless explicitly defined otherwise.\n",
+    "    Cuda functions are by default set to use `device=True`,\n",
+    "    unless explicitly defined otherwise..\n",
+    "    \n",
+    "    Parameters\n",
+    "    ----------\n",
+    "    compilation_mode : str\n",
+    "        The compilation mode to use.\n",
+    "        Will be checked with `is_valid_compilation_mode`.\n",
+    "        If None, the global COMPILATION_MODE will be used as soon as the function is decorated for static compilation.\n",
+    "        If DYNAMIC_COMPILATION_ENABLED, the function will always be compiled at runtime and\n",
+    "        thus by default returns a Python function.\n",
+    "        Static recompilation can be enforced by reimporting a module containing\n",
+    "        the function with importlib.reload(imported_module).\n",
+    "        If COMPILATION_MODE is Python and not DYNAMIC_COMPILATION_ENABLED, no compilation will be used.\n",
+    "        Default is None\n",
+    "    **decorator_kwargs\n",
+    "        Keyword arguments that will be passed to numba.jit or cuda.jit compilation decorators.\n",
+    "    \"\"\"\n",
+    "    if compilation_mode is None:\n",
+    "        if DYNAMIC_COMPILATION_ENABLED:\n",
+    "            compilation_mode = \"dynamic\"\n",
+    "        else:\n",
+    "            compilation_mode = COMPILATION_MODE\n",
+    "    elif COMPILATION_MODE.startswith(\"python\"):\n",
+    "        compilation_mode = \"python\"\n",
+    "    else:\n",
+    "        is_valid_compilation_mode(compilation_mode)\n",
+    "    def parse_compilation(current_compilation_mode, func):\n",
+    "        if current_compilation_mode.startswith(\"python\"):\n",
+    "            compiled_function = __copy_func(func)\n",
+    "        elif current_compilation_mode.startswith(\"numba\"):\n",
+    "            if \"nogil\" in decorator_kwargs:\n",
+    "                if \"nopython\" in decorator_kwargs:\n",
+    "                    compiled_function = numba.jit(func, **decorator_kwargs)\n",
+    "                else:\n",
+    "                    compiled_function = numba.jit(func, **decorator_kwargs, nopython=True)\n",
+    "            elif \"nopython\" in decorator_kwargs:\n",
+    "                compiled_function = numba.jit(func, **decorator_kwargs, nogil=True)\n",
+    "            else:\n",
+    "                compiled_function = numba.jit(func, **decorator_kwargs, nogil=True, nopython=True)\n",
+    "        elif current_compilation_mode.startswith(\"cuda\"):\n",
+    "            if \"device\" in decorator_kwargs:\n",
+    "                compiled_function = cuda.jit(func, **decorator_kwargs)\n",
+    "            else:\n",
+    "                compiled_function = cuda.jit(func, **decorator_kwargs, device=True)\n",
+    "        return compiled_function\n",
+    "    def decorated_function(func):\n",
+    "        if compilation_mode != \"dynamic\":\n",
+    "            is_valid_compilation_mode(compilation_mode)\n",
+    "            static_compiled_function = parse_compilation(compilation_mode, func)\n",
+    "            return functools.wraps(func)(static_compiled_function)\n",
+    "        else:\n",
+    "            def dynamic_compiled_function(*func_args, **func_kwargs):\n",
+    "                compiled_function = parse_compilation(COMPILATION_MODE, func)\n",
+    "                return compiled_function(*func_args, **func_kwargs)\n",
+    "            return functools.wraps(func)(dynamic_compiled_function)\n",
+    "    if _func is None:\n",
+    "        return decorated_function\n",
+    "    else:\n",
+    "        return decorated_function(_func)\n",
+    "\n",
+    "    \n",
+    "import types\n",
+    "import functools\n",
+    "\n",
+    "def __copy_func(f):\n",
+    "    \"\"\"Based on http://stackoverflow.com/a/6528148/190597 (Glenn Maynard)\"\"\"\n",
+    "    g = types.FunctionType(f.__code__, f.__globals__, name=f.__name__,\n",
+    "                           argdefs=f.__defaults__,\n",
+    "                           closure=f.__closure__)\n",
+    "    g = functools.update_wrapper(g, f)\n",
+    "    g.__kwdefaults__ = f.__kwdefaults__\n",
+    "    return g"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "0a9dd7c2",
+   "metadata": {},
+   "source": [
+    "Testing yields the expected results:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "367a650d",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<numba.cuda.compiler.Dispatcher object at 0x000001747EBCA670>\n",
+      "<class 'numba.cuda.compiler.Dispatcher'>\n"
+     ]
+    }
+   ],
+   "source": [
+    "import types\n",
+    "\n",
+    "set_compilation_mode(compilation_mode=\"numba-multithread\")\n",
+    "\n",
+    "@compile_function(compilation_mode=\"python\")\n",
+    "def test_func_python(x):\n",
+    "    \"\"\"Docstring test\"\"\"\n",
+    "    x[0] += 1\n",
+    "    \n",
+    "@compile_function(compilation_mode=\"numba\")\n",
+    "def test_func_numba(x):\n",
+    "    \"\"\"Docstring test\"\"\"\n",
+    "    x[0] += 1\n",
+    "\n",
+    "set_compilation_mode(enable_dynamic_compilation=True)\n",
+    "\n",
+    "@compile_function\n",
+    "def test_func_dynamic_runtime(x):\n",
+    "    \"\"\"Docstring test\"\"\"\n",
+    "    x[0] += 1\n",
+    "\n",
+    "set_compilation_mode(enable_dynamic_compilation=False, compilation_mode=\"numba-multithread\")\n",
+    "\n",
+    "@compile_function\n",
+    "def test_func_static_runtime_numba(x):\n",
+    "    \"\"\"Docstring test\"\"\"\n",
+    "    x[0] += 1\n",
+    "\n",
+    "a = np.zeros(1, dtype=np.int64)\n",
+    "assert(isinstance(test_func_python, types.FunctionType))\n",
+    "test_func_python(a)\n",
+    "assert(np.all(a == np.ones(1)))\n",
+    "\n",
+    "a = np.zeros(1)\n",
+    "assert(isinstance(test_func_numba, numba.core.registry.CPUDispatcher))\n",
+    "test_func_numba(a)\n",
+    "assert(np.all(a == np.ones(1)))\n",
+    "\n",
+    "if __GPU_AVAILABLE:\n",
+    "    @compile_function(compilation_mode=\"cuda\", device=None)\n",
+    "    def test_func_cuda(x):\n",
+    "        \"\"\"Docstring test\"\"\"\n",
+    "        x[0] += 1\n",
+    "\n",
+    "    # Cuda function cannot be tested from outside the GPU\n",
+    "    a = np.zeros(1)\n",
+    "    assert(isinstance(test_func_cuda, numba.cuda.compiler.Dispatcher))\n",
+    "    test_func_cuda.forall(1,1)(a)\n",
+    "    assert(np.all(a == np.ones(1)))\n",
+    "\n",
+    "set_compilation_mode(compilation_mode=\"python\")\n",
+    "a = np.zeros(1)\n",
+    "assert(isinstance(test_func_static_runtime_numba, numba.core.registry.CPUDispatcher))\n",
+    "test_func_static_runtime_numba(a)\n",
+    "assert(np.all(a == np.ones(1)))\n",
+    "\n",
+    "set_compilation_mode(compilation_mode=\"python\")\n",
+    "a = np.zeros(1)\n",
+    "assert(isinstance(test_func_dynamic_runtime, types.FunctionType))\n",
+    "test_func_dynamic_runtime(a)\n",
+    "assert(np.all(a == np.ones(1)))\n",
+    "\n",
+    "set_compilation_mode(compilation_mode=\"numba\")\n",
+    "a = np.zeros(1)\n",
+    "assert(isinstance(test_func_dynamic_runtime, types.FunctionType))\n",
+    "test_func_dynamic_runtime(a)\n",
+    "assert(np.all(a == np.ones(1)))\n",
+    "\n",
+    "# # Cuda function cannot be tested from outside the GPU\n",
+    "# set_compilation_mode(compilation_mode=\"cuda\")\n",
+    "# a = np.zeros(1)\n",
+    "# assert(isinstance(test_func_dynamic_runtime, types.FunctionType))\n",
+    "# test_func_dynamic_runtime.forall(1,1)(a)\n",
+    "# assert(np.all(a == np.ones(1)))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "22037748",
+   "metadata": {},
+   "source": [
+    "Next, we define the 'performance_function' decorator to take full advantage of both compilation and parallelization for maximal performance. Note that a 'performance_function' can not return values. Instead, it should store results in provided buffer arrays."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f8ae63c6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#export \n",
+    "\n",
+    "def performance_function(\n",
+    "    _func: callable = None,\n",
+    "    *,\n",
+    "    worker_count: int = None,\n",
+    "    compilation_mode: str = None,\n",
+    "    **decorator_kwargs,\n",
+    ") -> callable:\n",
+    "    \"\"\"A decorator to compile a given function and allow multithreading over an multiple indices.\n",
+    "    \n",
+    "    NOTE This should only be used on functions that are compilable.\n",
+    "    Functions that need to be decorated need to have an `index` argument as first argument.\n",
+    "    If an iterable is provided to the decorated function,\n",
+    "    the original (compiled) function will be applied to all elements of this iterable.\n",
+    "    The most efficient way to provide iterables are with ranges, but numpy arrays work as well.\n",
+    "    Functions can not return values,\n",
+    "    results should be stored in buffer arrays inside thge function instead.\n",
+    "    \n",
+    "    Parameters\n",
+    "    ----------\n",
+    "    worker_count : int\n",
+    "        The number of workers to use for multithreading.\n",
+    "        If None, the global MAX_WORKER_COUNT is used at runtime.\n",
+    "        Default is None.\n",
+    "    compilation_mode : str\n",
+    "        The compilation mode to use. Will be forwarded to the `compile_function` decorator.\n",
+    "    **decorator_kwargs\n",
+    "        Keyword arguments that will be passed to numba.jit or cuda.jit compilation decorators.\n",
+    "    \"\"\"\n",
+    "    if worker_count is not None:\n",
+    "        worker_count = set_worker_count(worker_count, set_global=False)\n",
+    "    if compilation_mode is None:\n",
+    "        if DYNAMIC_COMPILATION_ENABLED:\n",
+    "            compilation_mode = \"dynamic\"\n",
+    "        else:\n",
+    "            compilation_mode = COMPILATION_MODE\n",
+    "    elif COMPILATION_MODE.startswith(\"python\"):\n",
+    "        compilation_mode = \"python\"\n",
+    "    else:\n",
+    "        is_valid_compilation_mode(compilation_mode)\n",
+    "    def _decorated_function(func):\n",
+    "        if compilation_mode != \"dynamic\":\n",
+    "            compiled_function = compile_function(\n",
+    "                func,\n",
+    "                compilation_mode=compilation_mode,\n",
+    "                **decorator_kwargs\n",
+    "            )\n",
+    "        def _parallel_python(\n",
+    "            compiled_function,\n",
+    "            iterable,\n",
+    "            start,\n",
+    "            stop,\n",
+    "            step,\n",
+    "            *func_args\n",
+    "        ):\n",
+    "            if start != -1:\n",
+    "                for index in range(start, stop, step):\n",
+    "                    compiled_function(index, *func_args)\n",
+    "            else:\n",
+    "                for index in iterable:\n",
+    "                    compiled_function(index, *func_args)\n",
+    "        _parallel_numba = numba.njit(nogil=True)(_parallel_python)\n",
+    "        def _parallel_cuda(compiled_function, iterable, *func_args):\n",
+    "            cuda_func_dict = {\"cuda\": cuda, \"compiled_function\": compiled_function}\n",
+    "            # Cuda functions cannot handle tuple unpacking but need a fixed number of arguments.\n",
+    "            if isinstance(iterable, range):\n",
+    "                func_string = \", \".join(f\"arg{i}\" for i in range(len(func_args) + 3))\n",
+    "                cuda_string = (\n",
+    "                    f\"@cuda.jit\\n\"\n",
+    "                    f\"def cuda_func({func_string}):\\n\"\n",
+    "                    f\"    index = arg0 + arg2 * cuda.grid(1)\\n\"\n",
+    "                    f\"    compiled_function(index, {func_string[18:]})\\n\"\n",
+    "                )\n",
+    "                exec(cuda_string, cuda_func_dict)\n",
+    "                cuda_func_dict[\"cuda_func\"].forall(len(iterable), 1)(\n",
+    "                    iterable.start,\n",
+    "                    iterable.stop,\n",
+    "                    iterable.step,\n",
+    "                    *func_args\n",
+    "                )                \n",
+    "            else:\n",
+    "                func_string = \", \".join(f\"arg{i}\" for i in range(len(func_args) + 1))\n",
+    "                cuda_string = (\n",
+    "                    f\"@cuda.jit\\n\"\n",
+    "                    f\"def cuda_func({func_string}):\\n\"\n",
+    "                    f\"    index = arg0[cuda.grid(1)]\\n\"\n",
+    "                    f\"    compiled_function(index, {func_string[6:]})\\n\"\n",
+    "                )\n",
+    "                exec(cuda_string, cuda_func_dict)\n",
+    "                cuda_func_dict[\"cuda_func\"].forall(len(iterable), 1)(iterable, *func_args)\n",
+    "        def _performance_function(iterable, *func_args):\n",
+    "            if compilation_mode == \"dynamic\":\n",
+    "                selected_compilation_mode = COMPILATION_MODE\n",
+    "                _compiled_function = compile_function(\n",
+    "                    func,\n",
+    "                    compilation_mode=selected_compilation_mode,\n",
+    "                    **decorator_kwargs\n",
+    "                )\n",
+    "            else:\n",
+    "                _compiled_function = compiled_function\n",
+    "                selected_compilation_mode = compilation_mode\n",
+    "            try:\n",
+    "                iter(iterable)\n",
+    "            except TypeError:\n",
+    "                iterable = np.array([iterable])\n",
+    "            if worker_count is None:\n",
+    "                selected_worker_count = MAX_WORKER_COUNT\n",
+    "            else:\n",
+    "                selected_worker_count = worker_count\n",
+    "            if selected_compilation_mode == \"cuda\":\n",
+    "                _parallel_cuda(_compiled_function, iterable, *func_args)\n",
+    "            else:\n",
+    "                if \"python\" in selected_compilation_mode:\n",
+    "                    parallel_function = _parallel_python\n",
+    "                elif \"numba\" in selected_compilation_mode:\n",
+    "                    parallel_function = _parallel_numba\n",
+    "                else:\n",
+    "                    raise NotImplementedError(\n",
+    "                        f\"Compilation mode {selected_compilation_mode} is not valid. \"\n",
+    "                        \"This error should not be possible, something is seriously wrong!!!\"\n",
+    "                    )\n",
+    "                if (selected_compilation_mode in [\"python\", \"numba\"]) or (selected_worker_count == 1):\n",
+    "                    iterable_is_range = isinstance(iterable, range)\n",
+    "                    x = np.empty(0, dtype=np.int64) if iterable_is_range else iterable\n",
+    "                    parallel_function(\n",
+    "                        _compiled_function,\n",
+    "                        np.empty(0, dtype=np.int64) if iterable_is_range else iterable,\n",
+    "                        iterable.start if iterable_is_range else -1,\n",
+    "                        iterable.stop if iterable_is_range else -1,\n",
+    "                        iterable.step if iterable_is_range else -1,\n",
+    "                        *func_args\n",
+    "                    )\n",
+    "                else:\n",
+    "                    workers = []\n",
+    "                    for worker_id in range(selected_worker_count):\n",
+    "                        local_iterable = iterable[worker_id::selected_worker_count]\n",
+    "                        iterable_is_range = isinstance(local_iterable, range)\n",
+    "                        worker = threading.Thread(\n",
+    "                            target=parallel_function,\n",
+    "                            args=(\n",
+    "                                _compiled_function,\n",
+    "                                np.empty(0, dtype=np.int64) if iterable_is_range else local_iterable,\n",
+    "                                local_iterable.start if iterable_is_range else -1,\n",
+    "                                local_iterable.stop if iterable_is_range else -1,\n",
+    "                                local_iterable.step if iterable_is_range else -1,\n",
+    "                                *func_args\n",
+    "                            )\n",
+    "                        )\n",
+    "                        worker.start()\n",
+    "                        workers.append(worker)\n",
+    "                    for worker in workers:\n",
+    "                        worker.join()\n",
+    "                        del worker\n",
+    "        return functools.wraps(func)(_performance_function)\n",
+    "    if _func is None:\n",
+    "        return _decorated_function\n",
+    "    else:\n",
+    "        return _decorated_function(_func)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ee5956cf",
+   "metadata": {},
+   "source": [
+    "We test this function with a simple smoothing algorithm."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "624250cd",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Wall time: 9.54 s\n",
+      "Wall time: 10.7 s\n",
+      "Wall time: 959 ms\n",
+      "Wall time: 631 ms\n",
+      "Wall time: 1.41 s\n"
+     ]
+    }
+   ],
+   "source": [
+    "def smooth_func(index, in_array, out_array, window_size):\n",
+    "    min_index = max(index - window_size, 0)\n",
+    "    max_index = min(index + window_size + 1, len(in_array))\n",
+    "    smooth_value = 0\n",
+    "    for i in range(min_index, max_index):\n",
+    "        smooth_value += in_array[i]\n",
+    "    out_array[index] += smooth_value / (max_index - min_index)\n",
+    "\n",
+    "\n",
+    "set_compilation_mode(compilation_mode=\"numba-multithread\")\n",
+    "set_worker_count(0)\n",
+    "array_size = 10**6\n",
+    "smooth_factor = 10**4\n",
+    "\n",
+    "# python test\n",
+    "in_array = np.arange(array_size)\n",
+    "out_array = np.zeros_like(in_array)\n",
+    "\n",
+    "func = performance_function(compilation_mode=\"python\")(smooth_func)\n",
+    "%time func(range(in_array[::100].shape[0]), in_array[::100], out_array[::100], smooth_factor//10) #too slow to test otherwise\n",
+    "\n",
+    "# numba test\n",
+    "in_array = np.arange(array_size)\n",
+    "out_array = np.zeros_like(in_array)\n",
+    "\n",
+    "func = performance_function(compilation_mode=\"numba\")(smooth_func)\n",
+    "%time func(range(in_array.shape[0]), in_array, out_array, smooth_factor)\n",
+    "\n",
+    "# numba-multithread test\n",
+    "in_array = np.arange(array_size)\n",
+    "out_array = np.zeros_like(in_array)\n",
+    "\n",
+    "func = performance_function(compilation_mode=\"numba-multithread\")(smooth_func)\n",
+    "%time func(range(in_array.shape[0]), in_array, out_array, smooth_factor)\n",
+    "\n",
+    "# cuda test\n",
+    "if __GPU_AVAILABLE:\n",
+    "    in_array = cupy.arange(array_size)\n",
+    "    out_array = cupy.zeros_like(in_array)\n",
+    "\n",
+    "    func = performance_function(compilation_mode=\"cuda\")(smooth_func)\n",
+    "    %time func(range(in_array.shape[0]), in_array, out_array, smooth_factor)\n",
+    "    %time tmp = out_array.get()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "brilliant-adapter",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Converted 00_settings.ipynb.\n",
+      "Converted 01_chem.ipynb.\n",
+      "Converted 02_io.ipynb.\n",
+      "Converted 03_fasta.ipynb.\n",
+      "Converted 04_feature_finding.ipynb.\n",
+      "Converted 05_search.ipynb.\n",
+      "Converted 06_score.ipynb.\n",
+      "Converted 07_recalibration.ipynb.\n",
+      "Converted 08_quantification.ipynb.\n",
+      "Converted 09_matching.ipynb.\n",
+      "Converted 10_constants.ipynb.\n",
+      "Converted 11_interface.ipynb.\n",
+      "Converted 12_speed.ipynb.\n",
+      "Converted 13_performance.ipynb.\n",
+      "Converted index.ipynb.\n"
+     ]
+    }
+   ],
+   "source": [
+    "#hide\n",
+    "from nbdev.export import *\n",
+    "notebook2script()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/sandbox/Test_Run.ipynb b/sandbox/Test_Run.ipynb
index 9db127e1..58c6e3d4 100644
--- a/sandbox/Test_Run.ipynb
+++ b/sandbox/Test_Run.ipynb
@@ -62,52 +62,53 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:34:37> Logging to C:\\Users\\admin\\alphapept\\logs\\log_20210531223437.txt.\n",
-      "2021-05-31 22:34:37> Platform information:\n",
-      "2021-05-31 22:34:37> system     - Windows\n",
-      "2021-05-31 22:34:37> release    - 10\n",
-      "2021-05-31 22:34:37> version    - 10.0.18362\n",
-      "2021-05-31 22:34:37> machine    - AMD64\n",
-      "2021-05-31 22:34:37> processor  - Intel64 Family 6 Model 85 Stepping 4, GenuineIntel\n",
-      "2021-05-31 22:34:37> cpu count  - 24\n",
-      "2021-05-31 22:34:37> ram memory - 121.1/127.7 Gb (available/total)\n",
-      "2021-05-31 22:34:37> \n",
-      "2021-05-31 22:34:38> Python information:\n",
-      "2021-05-31 22:34:38> SQLAlchemy     - 1.3.22\n",
-      "2021-05-31 22:34:38> alphapept      - 0.3.24\n",
-      "2021-05-31 22:34:38> biopython      - 1.78\n",
-      "2021-05-31 22:34:38> click          - 7.1.2\n",
-      "2021-05-31 22:34:38> comtypes       - 1.1.7\n",
-      "2021-05-31 22:34:38> fastcore       - 1.3.5\n",
-      "2021-05-31 22:34:38> h5py           - 3.2.1\n",
-      "2021-05-31 22:34:38> lxml           - 4.6.2\n",
-      "2021-05-31 22:34:38> matplotlib     - 3.3.3\n",
-      "2021-05-31 22:34:38> nbdev          - 1.1.5\n",
-      "2021-05-31 22:34:38> networkx       - 2.5\n",
-      "2021-05-31 22:34:38> numba          - 0.53.1\n",
-      "2021-05-31 22:34:38> numpy          - 1.19.4\n",
-      "2021-05-31 22:34:38> pandas         - 1.2.1\n",
-      "2021-05-31 22:34:38> plotly_express - 0.4.0\n",
-      "2021-05-31 22:34:38> psutil         - 5.8.0\n",
-      "2021-05-31 22:34:38> python         - 3.8.8\n",
-      "2021-05-31 22:34:38> pythonnet      - 2.5.1\n",
-      "2021-05-31 22:34:38> pyyaml         - 5.3.1\n",
-      "2021-05-31 22:34:38> scikit-learn   - 0.23.2\n",
-      "2021-05-31 22:34:38> scipy          - 1.6.0\n",
-      "2021-05-31 22:34:38> seaborn        - 0.11.1\n",
-      "2021-05-31 22:34:38> streamlit      - 0.80.0\n",
-      "2021-05-31 22:34:38> tables         - 3.6.1\n",
-      "2021-05-31 22:34:38> tqdm           - 4.59.0\n",
-      "2021-05-31 22:34:38> watchdog       - 2.1.0\n",
-      "2021-05-31 22:34:38> \n",
-      "2021-05-31 22:34:38> Check for settings not completely implemented yet.\n",
-      "2021-05-31 22:34:38> Checking if files exist.\n",
-      "2021-05-31 22:34:38> Results path was not set. Setting to F:\\temp_test_run\\results.hdf\n",
-      "2021-05-31 22:34:38> Shortnames not set. Setting to filename.\n",
-      "2021-05-31 22:34:38> No database path set and save_db option checked. Using default path F:\\temp_test_run\\database.hdf\n",
-      "2021-05-31 22:34:38> Deleted F:/temp_test_run\\thermo_IRT.ms_data.hdf\n",
-      "2021-05-31 22:34:38> Workflow has 11 steps\n",
-      "2021-05-31 22:34:38> Database path set and exists. Using F:\\temp_test_run\\database.hdf as database.\n"
+      "2021-05-10 23:00:45> Logging to C:\\Users\\admin\\alphapept\\logs\\log_20210510230045.txt.\n",
+      "2021-05-10 23:00:45> Platform information:\n",
+      "2021-05-10 23:00:45> system     - Windows\n",
+      "2021-05-10 23:00:45> release    - 10\n",
+      "2021-05-10 23:00:45> version    - 10.0.18362\n",
+      "2021-05-10 23:00:45> machine    - AMD64\n",
+      "2021-05-10 23:00:45> processor  - Intel64 Family 6 Model 85 Stepping 4, GenuineIntel\n",
+      "2021-05-10 23:00:45> cpu count  - 24\n",
+      "2021-05-10 23:00:45> ram memory - 113.7/127.7 Gb (available/total)\n",
+      "2021-05-10 23:00:45> \n",
+      "2021-05-10 23:00:46> Python information:\n",
+      "2021-05-10 23:00:46> SQLAlchemy     - 1.3.22\n",
+      "2021-05-10 23:00:46> alphapept      - 0.3.21\n",
+      "2021-05-10 23:00:46> alphatims      - 0.2.0\n",
+      "2021-05-10 23:00:46> biopython      - 1.78\n",
+      "2021-05-10 23:00:46> click          - 7.1.2\n",
+      "2021-05-10 23:00:46> comtypes       - 1.1.7\n",
+      "2021-05-10 23:00:46> fastcore       - 1.3.5\n",
+      "2021-05-10 23:00:46> h5py           - 3.2.1\n",
+      "2021-05-10 23:00:46> lxml           - 4.6.2\n",
+      "2021-05-10 23:00:46> matplotlib     - 3.3.3\n",
+      "2021-05-10 23:00:46> nbdev          - 1.1.5\n",
+      "2021-05-10 23:00:46> networkx       - 2.5\n",
+      "2021-05-10 23:00:46> numba          - 0.53.1\n",
+      "2021-05-10 23:00:46> numpy          - 1.19.4\n",
+      "2021-05-10 23:00:46> pandas         - 1.2.3\n",
+      "2021-05-10 23:00:46> plotly-express - 0.4.0\n",
+      "2021-05-10 23:00:46> psutil         - 5.8.0\n",
+      "2021-05-10 23:00:46> python         - 3.8.8\n",
+      "2021-05-10 23:00:46> pythonnet      - 2.5.1\n",
+      "2021-05-10 23:00:46> pyyaml         - 5.3.1\n",
+      "2021-05-10 23:00:46> scikit-learn   - 0.23.2\n",
+      "2021-05-10 23:00:46> scipy          - 1.6.0\n",
+      "2021-05-10 23:00:46> seaborn        - 0.11.1\n",
+      "2021-05-10 23:00:46> streamlit      - 0.80.0\n",
+      "2021-05-10 23:00:46> tables         - 3.6.1\n",
+      "2021-05-10 23:00:46> tqdm           - 4.59.0\n",
+      "2021-05-10 23:00:46> watchdog       - 2.1.0\n",
+      "2021-05-10 23:00:46> \n",
+      "2021-05-10 23:00:46> Check for settings not completely implemented yet.\n",
+      "2021-05-10 23:00:46> Checking if files exist.\n",
+      "2021-05-10 23:00:46> Results path was not set. Setting to F:\\temp\\results.hdf\n",
+      "2021-05-10 23:00:46> Shortnames not set. Setting to filename.\n",
+      "2021-05-10 23:00:46> No database path set and save_db option checked. Using default path F:\\temp\\database.hdf\n",
+      "2021-05-10 23:00:46> Deleted F:/temp\\thermo_IRT.ms_data.hdf\n",
+      "2021-05-10 23:00:46> Workflow has 10 steps\n",
+      "2021-05-10 23:00:46> Database path set and exists. Using F:\\temp\\database.hdf as database.\n"
      ]
     },
     {
@@ -122,11 +123,11 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:34:38> Processing 1 files for step raw_conversion\n",
-      "2021-05-31 22:34:38> File F:/temp_test_run\\thermo_IRT has extension .raw - converting from Thermo.\n",
-      "2021-05-31 22:34:42> File conversion complete. Extracted 2,603 precursors.\n",
-      "2021-05-31 22:34:42> File conversion of file F:/temp_test_run\\thermo_IRT.raw complete.\n",
-      "2021-05-31 22:34:42> init\n"
+      "2021-05-10 23:00:46> Processing 1 files for step raw_conversion\n",
+      "2021-05-10 23:00:46> File F:/temp\\thermo_IRT has extension .raw - converting from Thermo.\n",
+      "2021-05-10 23:00:50> File conversion complete. Extracted 2,603 precursors.\n",
+      "2021-05-10 23:00:50> File conversion of file F:/temp\\thermo_IRT.raw complete.\n",
+      "2021-05-10 23:00:50> init\n"
      ]
     },
     {
@@ -141,25 +142,25 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:34:44> Processing 1 files for step find_features\n",
-      "2021-05-31 22:34:44> No *.hdf file with features found for F:/temp_test_run\\thermo_IRT.ms_data.hdf. Adding to feature finding list.\n",
-      "2021-05-31 22:34:44> Feature finding on F:/temp_test_run\\thermo_IRT.raw\n",
-      "2021-05-31 22:34:46> Number of hills 45,516, len = 20.18\n",
-      "2021-05-31 22:34:46> Repeating hill extraction with centroid_tol 5.10\n",
-      "2021-05-31 22:34:47> Number of hills 52,919, len = 21.29\n",
-      "2021-05-31 22:34:53> After split hill_ptrs 79,545\n",
-      "2021-05-31 22:34:54> After filter hill_ptrs 77,220\n",
-      "2021-05-31 22:35:06> Extracting hill stats complete\n",
-      "2021-05-31 22:35:09> Found 7,527 pre isotope patterns.\n",
-      "2021-05-31 22:35:22> Extracted 7,135 isotope patterns.\n",
-      "2021-05-31 22:35:28> Report complete.\n",
-      "2021-05-31 22:35:28> Note: NumExpr detected 24 cores but \"NUMEXPR_MAX_THREADS\" not set, so enforcing safe limit of 8.\n",
-      "2021-05-31 22:35:28> NumExpr defaulting to 8 threads.\n",
-      "2021-05-31 22:35:28> Matching features to query data.\n",
-      "2021-05-31 22:35:28> Saving feature table.\n",
-      "2021-05-31 22:35:28> Feature table saved to F:/temp_test_run\\thermo_IRT.ms_data.hdf\n",
-      "2021-05-31 22:35:28> Saving features.\n",
-      "2021-05-31 22:35:28> Feature finding of file F:/temp_test_run\\thermo_IRT.raw complete.\n"
+      "2021-05-10 23:00:52> Processing 1 files for step find_features\n",
+      "2021-05-10 23:00:52> No *.hdf file with features found for F:/temp\\thermo_IRT.ms_data.hdf. Adding to feature finding list.\n",
+      "2021-05-10 23:00:52> Feature finding on F:/temp\\thermo_IRT.raw\n",
+      "2021-05-10 23:00:55> Number of hills 45,516, len = 20.18\n",
+      "2021-05-10 23:00:55> Repeating hill extraction with ppm_tol 5.10\n",
+      "2021-05-10 23:00:56> Number of hills 52,919, len = 21.29\n",
+      "2021-05-10 23:01:02> After split hill_ptrs 79,545\n",
+      "2021-05-10 23:01:04> After filter hill_ptrs 77,220\n",
+      "2021-05-10 23:01:17> Extracting hill stats complete\n",
+      "2021-05-10 23:01:20> Found 7,527 pre isotope patterns.\n",
+      "2021-05-10 23:01:35> Extracted 7,578 isotope patterns.\n",
+      "2021-05-10 23:01:42> Report complete.\n",
+      "2021-05-10 23:01:42> Matching features to query data.\n",
+      "2021-05-10 23:01:42> Note: NumExpr detected 24 cores but \"NUMEXPR_MAX_THREADS\" not set, so enforcing safe limit of 8.\n",
+      "2021-05-10 23:01:42> NumExpr defaulting to 8 threads.\n",
+      "2021-05-10 23:01:42> Saving feature table.\n",
+      "2021-05-10 23:01:42> Feature table saved to F:/temp\\thermo_IRT.ms_data.hdf\n",
+      "2021-05-10 23:01:42> Saving features.\n",
+      "2021-05-10 23:01:43> Feature finding of file F:/temp\\thermo_IRT.raw complete.\n"
      ]
     },
     {
@@ -174,29 +175,29 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:28> Starting first search.\n",
-      "2021-05-31 22:35:29> Processing 1 files for step search_db\n",
-      "2021-05-31 22:35:29> Calibrating fragments\n",
-      "2021-05-31 22:35:30> Performing search on 3,187 query and 44 db entries with frag_tol = 30.00 and prec_tol = 30.00.\n"
+      "2021-05-10 23:01:43> Starting first search.\n",
+      "2021-05-10 23:01:43> Processing 1 files for step search_db\n",
+      "2021-05-10 23:01:43> Calibrating fragments\n",
+      "2021-05-10 23:01:44> Performing search on 1,533 query and 40 db entries with m_tol = 30.00 and m_offset = 30.00.\n"
      ]
     },
     {
      "name": "stderr",
      "output_type": "stream",
      "text": [
-      "  0%|                                                                                            | 0/1 [00:48<?, ?it/s]\n"
+      "  0%|                                                                                            | 0/1 [00:53<?, ?it/s]\n"
      ]
     },
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:34> Compared 172 spectra and found 31 psms.\n",
-      "2021-05-31 22:35:34> Extracting columns for scoring.\n",
-      "2021-05-31 22:35:38> Extracted columns from 31 spectra.\n",
-      "2021-05-31 22:35:38> Saving first_search results to F:/temp_test_run\\thermo_IRT.ms_data.hdf\n",
-      "2021-05-31 22:35:39> Search of file F:/temp_test_run\\thermo_IRT.raw complete.\n",
-      "2021-05-31 22:35:39> First search complete.\n"
+      "2021-05-10 23:01:48> Compared 128 spectra and found 27 psms.\n",
+      "2021-05-10 23:01:48> Extracting columns for scoring.\n",
+      "2021-05-10 23:01:54> Extracted columns from 27 spectra.\n",
+      "2021-05-10 23:01:54> Saving first_search results to F:/temp\\thermo_IRT.ms_data.hdf\n",
+      "2021-05-10 23:01:54> Search of file F:/temp\\thermo_IRT.raw complete.\n",
+      "2021-05-10 23:01:54> First search complete.\n"
      ]
     },
     {
@@ -211,14 +212,14 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:39> Processing 1 files for step calibrate_hdf\n",
-      "2021-05-31 22:35:39> Scoring using X-Tandem\n",
-      "2021-05-31 22:35:39> Last q_value 0.000 of dataset is smaller than fdr_level 0.010\n",
-      "2021-05-31 22:35:39> 13 target (0 decoy) of 13 PSMs. fdr 0.000000 for a cutoff of 18.50 (set fdr was 0.01)\n",
-      "2021-05-31 22:35:39> Not enough data points present. Skipping recalibration.\n",
-      "2021-05-31 22:35:39> Calibration of file F:/temp_test_run\\thermo_IRT.ms_data.hdf complete.\n",
-      "2021-05-31 22:35:39> Calibrating fragments\n",
-      "2021-05-31 22:35:39> Median fragment offset -0.89 - std 16.56 ppm\n"
+      "2021-05-10 23:01:54> Processing 1 files for step calibrate_hdf\n",
+      "2021-05-10 23:01:54> Scoring using X-Tandem\n",
+      "2021-05-10 23:01:54> Last q_value 0.000 of dataset is smaller than fdr_level 0.010\n",
+      "2021-05-10 23:01:54> 11 target (0 decoy) of 11 PSMs. fdr 0.000000 for a cutoff of 16.81 (set fdr was 0.01)\n",
+      "2021-05-10 23:01:54> Not enough data points present. Skipping recalibration.\n",
+      "2021-05-10 23:01:54> Calibration of file F:/temp\\thermo_IRT.ms_data.hdf complete.\n",
+      "2021-05-10 23:01:54> Calibrating fragments\n",
+      "2021-05-10 23:01:54> Median fragment offset 10.91 - std 13.28 ppm\n"
      ]
     },
     {
@@ -234,16 +235,16 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:39> Starting second search with DB.\n",
-      "2021-05-31 22:35:39> Processing 1 files for step search_db\n",
-      "2021-05-31 22:35:39> Found calibrated prec_tol with value 7.58\n",
-      "2021-05-31 22:35:39> Performing search on 3,187 query and 44 db entries with frag_tol = 30.00 and prec_tol = 7.58.\n",
-      "2021-05-31 22:35:39> Compared 160 spectra and found 27 psms.\n",
-      "2021-05-31 22:35:39> Extracting columns for scoring.\n",
-      "2021-05-31 22:35:39> Extracted columns from 27 spectra.\n",
-      "2021-05-31 22:35:39> Saving second_search results to F:/temp_test_run\\thermo_IRT.ms_data.hdf\n",
-      "2021-05-31 22:35:39> Search of file F:/temp_test_run\\thermo_IRT.raw complete.\n",
-      "2021-05-31 22:35:39> Second search complete.\n"
+      "2021-05-10 23:01:54> Starting second search with DB.\n",
+      "2021-05-10 23:01:54> Processing 1 files for step search_db\n",
+      "2021-05-10 23:01:54> Found calibrated m_offset with value 11.01\n",
+      "2021-05-10 23:01:54> Performing search on 1,533 query and 40 db entries with m_tol = 30.00 and m_offset = 11.01.\n",
+      "2021-05-10 23:01:54> Compared 128 spectra and found 24 psms.\n",
+      "2021-05-10 23:01:54> Extracting columns for scoring.\n",
+      "2021-05-10 23:01:54> Extracted columns from 24 spectra.\n",
+      "2021-05-10 23:01:54> Saving second_search results to F:/temp\\thermo_IRT.ms_data.hdf\n",
+      "2021-05-10 23:01:54> Search of file F:/temp\\thermo_IRT.raw complete.\n",
+      "2021-05-10 23:01:54> Second search complete.\n"
      ]
     },
     {
@@ -260,23 +261,21 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:39> Processing 1 files for step score_hdf\n",
-      "2021-05-31 22:35:39> Found second search psms for scoring.\n",
-      "2021-05-31 22:35:40> Last q_value 0.000 of dataset is smaller than fdr_level 0.100\n",
-      "2021-05-31 22:35:40> 12 target (0 decoy) of 12 PSMs. fdr 0.000000 for a cutoff of 18.50 (set fdr was 0.1)\n",
-      "2021-05-31 22:35:40> The total number of available decoys is lower than the initial set of high scoring targets.\n",
-      "2021-05-31 22:35:40> ML failed. Defaulting to x_tandem score\n",
-      "2021-05-31 22:35:40> There are fewer high scoring targets or decoys than required by 'min_train'.\n",
-      "2021-05-31 22:35:40> Converting x_tandem score to probabilities\n",
-      "2021-05-31 22:35:40> Filter df with custom score\n",
-      "2021-05-31 22:35:40> Global FDR on precursor\n",
-      "2021-05-31 22:35:40> Last q_value 0.000 of dataset is smaller than fdr_level 0.010\n",
-      "2021-05-31 22:35:40> 12 target (0 decoy) of 12 PSMs. fdr 0.000000 for a cutoff of 0.04 (set fdr was 0.01)\n",
-      "2021-05-31 22:35:40> FDR on peptides complete. For 0.01 FDR found 12 targets and 0 decoys.\n",
-      "2021-05-31 22:35:40> Extracting ions\n",
-      "2021-05-31 22:35:40> Extracting ions complete.\n",
-      "2021-05-31 22:35:40> Scoring of file F:/temp_test_run\\thermo_IRT.ms_data.hdf complete.\n",
-      "2021-05-31 22:35:40> Only 1 dataset present. Skipping alignment.\n"
+      "2021-05-10 23:01:54> Processing 1 files for step score_hdf\n",
+      "2021-05-10 23:01:54> Found second search psms for scoring.\n",
+      "2021-05-10 23:01:55> Last q_value 0.000 of dataset is smaller than fdr_level 0.100\n",
+      "2021-05-10 23:01:55> 11 target (0 decoy) of 11 PSMs. fdr 0.000000 for a cutoff of 18.09 (set fdr was 0.1)\n",
+      "2021-05-10 23:01:55> The total number of available decoys is lower than the initial set of high scoring targets.\n",
+      "2021-05-10 23:01:55> ML failed. Defaulting to x_tandem score\n",
+      "2021-05-10 23:01:55> There are fewer high scoring targets or decoys than required by 'min_train'.\n",
+      "2021-05-10 23:01:55> Filter df with x_tandem score\n",
+      "2021-05-10 23:01:55> Global FDR on precursor\n",
+      "2021-05-10 23:01:55> Last q_value 0.000 of dataset is smaller than fdr_level 0.010\n",
+      "2021-05-10 23:01:55> 8 target (0 decoy) of 8 PSMs. fdr 0.000000 for a cutoff of 18.09 (set fdr was 0.01)\n",
+      "2021-05-10 23:01:55> FDR on peptides complete. For 0.01 FDR found 11 targets and 0 decoys.\n",
+      "2021-05-10 23:01:55> Scoring of file F:/temp\\thermo_IRT.ms_data.hdf complete.\n",
+      "2021-05-10 23:01:55> Fasta dict with length 1, Pept dict with length 40\n",
+      "2021-05-10 23:01:55> Extracting protein groups.\n"
      ]
     },
     {
@@ -294,38 +293,37 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:40> Fasta dict with length 1, Pept dict with length 44\n",
-      "2021-05-31 22:35:40> Extracting protein groups.\n"
+      "2021-05-10 23:01:55> A total of 1 proteins with unique PSMs found\n",
+      "2021-05-10 23:01:55> A total of 0 ambigious proteins\n",
+      "2021-05-10 23:01:55> A total of 0 proteins with unique PSMs found\n",
+      "2021-05-10 23:01:55> A total of 0 ambigious proteins\n",
+      "2021-05-10 23:01:55> Global FDR on protein\n",
+      "2021-05-10 23:01:55> Last q_value 0.000 of dataset is smaller than fdr_level 0.010\n",
+      "2021-05-10 23:01:55> 1 target (0 decoy) of 1 PSMs. fdr 0.000000 for a cutoff of 42.12 (set fdr was 0.01)\n",
+      "2021-05-10 23:01:55> FDR on proteins complete. For 0.01 FDR found 11 targets and 0 decoys. A total of 1 proteins found.\n",
+      "2021-05-10 23:01:55> Extracting ions\n",
+      "2021-05-10 23:01:55> Extracting ions complete.\n",
+      "2021-05-10 23:01:55> Saving complete.\n",
+      "2021-05-10 23:01:55> Protein groups complete.\n",
+      "2021-05-10 23:01:55> Only 1 dataset present. Skipping alignment.\n",
+      "2021-05-10 23:01:55> Assembling dataframe.\n"
      ]
     },
     {
      "name": "stderr",
      "output_type": "stream",
      "text": [
-      "\n",
-      "\n",
-      "\n",
-      "\n",
-      "\n",
-      "  0%|                                                                                            | 0/1 [00:00<?, ?it/s]\u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\n"
+      "\r",
+      "  0%|                                                                                            | 0/1 [00:00<?, ?it/s]"
      ]
     },
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:40> A total of 10 unique and 0 shared peptides.\n",
-      "2021-05-31 22:35:40> A total of 0 ambigious proteins\n",
-      "2021-05-31 22:35:40> A total of 0 unique and 0 shared peptides.\n",
-      "2021-05-31 22:35:40> A total of 0 ambigious proteins\n",
-      "2021-05-31 22:35:40> Global FDR on protein_group\n",
-      "2021-05-31 22:35:40> Last q_value 0.000 of dataset is smaller than fdr_level 0.010\n",
-      "2021-05-31 22:35:40> 1 target (0 decoy) of 1 PSMs. fdr 0.000000 for a cutoff of 1.00 (set fdr was 0.01)\n",
-      "2021-05-31 22:35:40> FDR on proteins complete. For 0.01 FDR found 12 targets and 0 decoys. A total of 1 proteins found.\n",
-      "2021-05-31 22:35:40> Saving complete.\n",
-      "2021-05-31 22:35:40> Protein groups complete.\n",
-      "2021-05-31 22:35:40> Complete. \n",
-      "2021-05-31 22:35:40> Starting profile extraction.\n"
+      "2021-05-10 23:01:56> Assembly complete.\n",
+      "2021-05-10 23:01:56> Complete. \n",
+      "2021-05-10 23:01:56> Starting profile extraction.\n"
      ]
     },
     {
@@ -351,24 +349,11 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "2021-05-31 22:35:40> LFQ complete.\n",
-      "2021-05-31 22:35:40> Exporting as csv.\n",
-      "2021-05-31 22:35:40> Saved df of length 12 saved to F:\\temp_test_run\\results\n"
-     ]
-    },
-    {
-     "name": "stderr",
-     "output_type": "stream",
-     "text": [
-      "100%|████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 12.08it/s]\n"
-     ]
-    },
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "2021-05-31 22:35:40> Settings saved to F:\\temp_test_run\\results.yaml\n",
-      "2021-05-31 22:35:40> Analysis complete.\n"
+      "2021-05-10 23:01:56> LFQ complete.\n",
+      "2021-05-10 23:01:56> Exporting as csv.\n",
+      "2021-05-10 23:01:56> Saved df of length 11 saved to F:\\temp\\results\n",
+      "2021-05-10 23:01:56> Settings saved to F:\\temp\\results.yaml\n",
+      "2021-05-10 23:01:56> Analysis complete.\n"
      ]
     }
    ],
@@ -574,7 +559,7 @@
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
-       "<p>5 rows × 60 columns</p>\n",
+       "<p>5 rows × 70 columns</p>\n",
        "</div>"
       ],
       "text/plain": [
@@ -606,7 +591,7 @@
        "3           2      12  \n",
        "4           0       8  \n",
        "\n",
-       "[5 rows x 60 columns]"
+       "[5 rows x 70 columns]"
       ]
      },
      "execution_count": 3,
@@ -643,6 +628,14 @@
    "execution_count": 4,
    "metadata": {},
    "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\r",
+      "100%|████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00,  3.95it/s]"
+     ]
+    },
     {
      "name": "stdout",
      "output_type": "stream",
@@ -669,6 +662,16 @@
       " Plotting PSMS for aLGGNEQVTR_2\n"
      ]
     },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "  0%|                                                                                            | 0/1 [00:13<?, ?it/s]\n",
+      "  0%|                                                                                            | 0/1 [00:02<?, ?it/s]\n",
+      "  0%|                                                                                            | 0/1 [00:02<?, ?it/s]\n",
+      "  0%|                                                                                            | 0/1 [00:01<?, ?it/s]\n"
+     ]
+    },
     {
      "data": {
       "image/png": "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YwmWDrkX+Go2x0FE0smcy+aqChER8VFYOb0LOD38kC0JMrP/ANY55/6vnn0qg4I+7l/LuXjLzOYRXHq8IdO+IiKSXgocRUSauPDSwt1u3/UixVNm9k1gmHPunKTHIiIiki0FjiIikjUze4fg8teabmhkk5qCYmZLCZrBXBIuESEiItIkKHAUERERERGReqk5joiIiIiIiNRLgaOIiIiIiIjUq1nSA/BFu3btXHFxcdLDEBERERERScScOXPWO+eOrO1nChxDxcXFzJ49O+lhiIiIiIiIJMLMltX1M12qKiIiIiIiIvVS4CgiIiIiIiL1UuAoIiIiIiIi9dIcRxERERERKRi7d+9mxYoV7Ny5M+mhJKZFixZ07NiR5s2bZ30bBY4iIiIiIlIwVqxYwaGHHkpxcTFmlvRwYuecY8OGDaxYsYLOnTtnfTtdqioiIiIiIgVj586dtG3btiCDRgAzo23btg2uuCpwFBERERGRglKoQWOlxjx/BY4iIiIiIiIxWrFiBcOHD6dr16506dKFsWPHsmvXrqSHVS8FjiIiIiIiIjFxzvG5z32OSy65hPfff5/333+fHTt28O1vfzvyfe/ZsycHI6ydAkcREREREZGYvPTSS7Ro0YKrr74agKKiIv7nf/6H8ePHM27cOMaOHVu177Bhw5g+fToAzz33HKeeeip9+vTh85//PNu2bQOguLiY2267jT59+nD33XfTp0+fqtu///77+3wfhQJHERERERGRmLzzzjv07dt3n22HHXYYxcXFfPrpp7XeZv369fzwhz/khRde4K233qJfv378/Oc/r/p527Zteeutt7jjjjto3bo18+bNA+D++++vClCj0nIcIiIiIhEUFRXRs2dPAI477jieeuqp/fZZunQpnTt35le/+hVf/epXARg7diz9+vVjzJgxjBkzhmHDhjFy5Miq2xxyyCFs27aNZcuWMWLECPbu3cvu3bv56le/yo033hjPk0upf/7zn1x33XUsX74cM+OZZ56huLh4n32a4ms2bdo0vvGNb1R9/+677zJx4kQuueSSJv/c8qm8vDyn9zdmzJic3h/A66+/zsKFCzn99NMB+OSTTzj11FOrfn7ZZZdVfX3ddddx//338/Of/5xHHnmEN998MydjUOAoIiIiEkHLli2rsvv1ad++Pb/85S+54YYbOPDAA7O+/2OOOYaZM2dy0EEHsW3bNsrKyrj44os59thjI4y6sI0ePZo77riDwYMHs23bNg44oPaL8JraazZw4MCq9+LGjRspKSnhvPPOq3Xfpvbc0qRHjx5MmjRpn21bt25l9erVtG3blvfee69qe+WSGc45Bg8ezMMPP1zrfR588MFVX//7v/87P/jBDzjnnHPo27cvbdu2zcm4FTiKiIiIZOF73/seRxxxBF//+tcBuOOOO2jfvn3Wtz/yyCM5/fTTeeCBB/jSl76U9e2qf6jftWsXe/fuzfq2ha6u1+zTTz9l8ODBQFBJq4vPr1ldz+3mm28GYNKkSVxwwQW0atWq1tv7/Nzilo8KYX0GDRrE7bffzvjx4xk9ejR79uzhW9/6FmPHjqVz58789re/Ze/evaxcubKqWti/f39uuukmFi9eTElJCR9//DErV67khBNO2O/+W7Rowfnnn8+Xv/xl7rvvvpyNW3McRURERLJwzTXXMH78eAD27t3LxIkTufLKK9m5cyf9+vWjf//+PPnkk/Xex2233cY999xTa+fDW2+9ld69e1f9q2758uX06tWLTp06cdttt6m6k6XaXrOOHTvSpk0bPve5z3HSSSdx66231tuJ0tfXrK73Y6WJEydyxRVX1Hsfvj63tDMznnjiCSZNmkTXrl1p27YtBxxwAHfccQenn346nTt3pkePHnzta1+ramxz5JFHUl5ezhVXXEGvXr049dRTeffdd+t8jFGjRnHAAQfUWXFuDFUcRURERLJQXFxM27ZtmTt3LmvWrOGkk06ibdu2LFu2jA4dOrBkyRLOOeccevbsyfHHH1/rfXTp0oVTTjmFP/3pT/v97Gc/+9l+c8oqderUifnz5/Phhx9yySWXMHLkSI466qjcP8mUqe01c87x6quvMnfuXI477jguu+wyysvLufbaa2u9D19fs7rejwCrVq3i7bff5vzzz6/3Pnx9boWgU6dOVfOhX3vtNa644greeust+vTpw4QJE2q9zTnnnMOsWbP227506dL9ts2YMYOrr76aoqKinI1ZFUcRERGRLF133XWUl5dz//33c8011wDQoUMHIPgQPmDAAObOncsbb7xRVamp2Sznu9/9Lj/5yU9wzjX48Y899ljKysp49dVXoz+ZAlHzNevYsSO9e/emS5cuNGvWjEsuuYS33nqrSb5mtb0fAR599FFGjBhB8+bN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r9bb6/yTrMpj/9e//pW5c+eyZs0aTjrpJNq2bcuyZcvo0KEDS5Ys4ZxzzqFnz54cf/zx+93H8ccfv8881jvvvHO/fX70ox/RrFkzRo0ala+nUlB27twJBOvupUlFRdBzsm3btvu9HwFWrVrF22+/zfnnn1/nfej9KI0RR1fVmm4DvmlmiwnmI94Xbr8PaBtu/yZwO4Bz7h3gUWAhMBW4yTm3J5zDOBb4K0HX1kfDfet7DClgQd+k/b//wQ9+wLp16/ZZC+f888+vt5JT04oVKxgxYgTjx4+v+sDQoUOHfS59XbFiBR06aLqtBGq+H2fNmsXixYspKSmhuLiY7du3U1JSwp49e6oywnUFFTXNnz+f6667jsmTJ1d9kOjQoQPLl/9rWrjej/Gq7fxzzDHHYGYcdNBBXH311VWXfOn845/KytV1111HeXk5999/P9dccw1A1XHt0qULAwYMYO7cubzxxhtVv7fZznssLy9nypQpTJgwoer9ot/baCZOnMjEiROTHkbOvfHGG7zxxhu1vh8BHn30UUaMGEHz5s2r9tf70T81m+CVl5czduxYAO69996qinJ5eTkffvhh7OOrlXNO/5yjb9++TtLrgw8+cIB77bXXnHPOXXvtte6ee+5xv//9792pp57qtm/fXu/tzz77bDdr1qx97q+0tNQ559ymTZtcr1693J///Of9bnfyySe7mTNnur1797ohQ4a4v/zlLzl8VtJU1fV+rO7ggw+u8/af+cxn3Lp166q+nzZtmrvwwgudc84tW7bMHX/88e5vf/vbPrfZvXu369y5s1uyZInbtWuX69Wrl1uwYEGunpLUo67X+8MPP3TOObd371538803u9tuu63W2+v8k7wdO3a4HTt2uF27drkTTjjBde7c2X366adu48aNbufOnc4559atW+dKSkrcO++8s9/tq79mlb7//e+7n/3sZ84555599lnXvXt3t3bt2n32WbBggevVq5fbuXOnW7JkSdXjSnYWLlzoFi5cmPQwcq6u92OlU045xb300kt13l7vR+fF+6Lm3/n777/f3XTTTfvtV/NvQC7VdhyA2a6OeClvl6qK+KZbt278+te/5pprrqFHjx58+ctf5rDDDuMzn/kMp556KgCf+9znsq7qVBo3bhyLFy/mrrvu4q677gLgueeeo3379vzmN79hzJgx7Nixgwsu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o2/fvk788sEHH7iTTjrJOefcnj17XJcuXdz69eudc85NmzbNXXjhhRnv4/7773f3339/PoeZWvUdf+ece+ONN9yJJ57o9uzZk9QQm5x169a5devWZdyvrmP/z3/+0/Xs2dO1bNnSjRs3Lt/DlYiyOf/U9VqfffbZbvny5e7+++93N910UwyjlbqsWbPGzZo1y33wwQeR76uu17uoqMj17dvXnXLKKe6JJ56I/DgSyPacW5/aXrNJkya5Cy+80I0YMcL17t3b3XLLLe7TTz/NxZCz8uabb7o333wz8v1k+js/cOBA9/TTT0d+nCTU9dy+9a1vudatW7vDDjvMffe73014lH4CZrs64iVVHMVbxcXFtG3blrlz57JmzRpOOukk2rZt26D70DpqjVff8V+1ahVXXXUVDzzwQKyX5zR12S4IX9exb9u2LfPnz+fDDz/kkksuYeTIkcqWemzIkCEZ96nttX744YcZOnQoHTt2jGGUkkkumxzV9bu9bNkyOnTowJIlSzjnnHPo2bMnxx9/fOTHK3TZnnPrU9tr5pzj1VdfZe7cuRx33HFcdtlllJeXc+211+Zo5PV74403ADj55JMj3U+mv/Nvv/02559/fuTxJqG257Zp0yYqKipYsWIFEFRuX331Vc4888yER9t0KHAUr1133XWUl5ezevVqrrnmmgbfXh3Hoqnt+G/dupULL7yQH/3oR/Tv3z/hETYtDUlk1PfeP/bYYykrK+PVV19l5MiRuR6m5Eh9l6hWV/O1njBhAq+++iq/+c1v2LZtG5988gmHHHIId999d55HLLUZPHgwGzduZP369TmZ9lDb73blfNguXbowYMAA5s6dq8AxB3KVPK75mh1xxBH07t27qmHSJZdcwuuvvx5b4JjLS2Pr+lvz6KOPMmLEiP2WamhKaj63J554gv79+1cdvwsuuICZM2cqcGwAreMY0jqOfvrkk0/o2bMnu3fv5v3336eoqAiA6dOnc88991RlE+uiddSiqXn89+zZwwUXXMBFF11UNW9A8qPmsV+1ahVt27alZcuWbNq0iVNOOYU///nP9OzZM+mhSh1WrlwJZG6SU9d5DoL1A2fPns24cePyOlapXy7Xcaz5em/dupVWrVpx0EEHsX79ek499VQmT55Mjx49Ij+W5EbN1wygT58+vPDCCxx55JFcffXV9OvXj5tuuimW8WQzfzpbdZ1/+vfvz49//GMGDhwY+TGSUvO5TZo0id///vdMnToV5xxDhgzh61//OhdddFHSQ/WK1nGUJuvAAw9k4MCBtGnTpupkduaZZ/Luu++ybds2OnbsyH333VfnpRRaRy2amsf/4Ycf5pVXXmHDhg1VH6TKy8vp3bt3ouNsKhqSyKh57CsqKvjWt76FmeGc45ZbblHQ6Lnnn38eyHz+qe08J/5YvXo1PXv2zFkCsrbf7RtuuIEDDjiAvXv3cvvttytozJFcJY9r+x295557GDRoUGWfDL70pS9FHm+2Kpf+yEXgWNtzW7p0KcuXL+fss8+OfP9JqvncRo4cyUsvvUTPnj0xM4YMGaKgsYFUcQyp4uiP6pndvXv30qdPHx577DG6du3a4PvKNuMv+1q8eDEQXDYV5fjLvhpStYj63pfkZfrQWvl+GD16tF5rj+Wy2gj63Y5T1NfO19/RX//61wCRKpwzrrsOgNN+9zuvnlsu6XetcVRxlCZl+PDhACxcuJBhw4YxYsSIRv/CK2BsnBkzZrBy5Uruu+++SMdf9pVNC/UlS5bw/vvv8+Uvf1nHvonLVOW49NJLqaiooKSkRK+1x4YMGcL69etZu3ZtpMrV4sWLef/997npppv0esck6rIVQ4cOZdGiRd79jh588ME5uZ8PNm1itGfPLarKy3g3btwY+TOk7E8Vx5AqjumUy3kAhWTbtm1A/GtTSe6rG5Kc5cuXA2rSlQa5+L3U77bkSi4uwa2sOJ7xhz/kZEy+0O9ZdKo4SpOyZMkSgKpuZVFMnToV0AmkoRQw5kc2iYwRI0bENRzJsxdffBGo+/xTUVEBQPfu3eMakjTCypUrKSsr45hjjol0P+qAHL+oyWNfkz+V00nU+G9/2SyDJI2nwFG888orrwC5CRx1AmmcRYsWAdCtW7eER5Iu2SQyWrduHdNoJN+GDRtW788r12JT4Oi3yiZH/frVmoDPmhJy8YuaPM6U/EnK3LlzATjttNMSHol/dIVZfilwFO/ksuKiE0jjzJw5E1DgmGvZJDIqM8klJSX5Ho7kWaY1/y6//PKYRiJRDB06lLVr17J69epIf1OUkItf1ORxpuRPUnI1xzGN1BQxvxQ4indyWXHRCaRxLr300qSHkErZfOicMWMGoMAxDSpb5hcXF9f68xYtWsQ3GGm09u3b52RpJyXk4hc1eZwp+ZMUXwNaH2S7DJI0jgJH8U4uKy46gTROq1atkh5CKmWTyNA8qPSYPn06UPf5Z8GCBQCUlZXFNCJpjOXLl9OzZ8/ICUgl5OIXNXmcKfmTlHfffReAM844I+GR+Gfo0KFJDyHVFDiKd3JZcdEJpHHUtCM/sklkaB5UelQuLVSXyk7eChz9VjnPrW/fvpHuRwm5+EVNHmdK/iRl/vz5gALH2qhhUH4pcBTv5LLiohNI46hpR35kk8jQPKj0OPzww+v9+ahRo2IaiUQxbNgwVq1axcqVKyNVHZWQi1/U5HGm5E9SlISom6+dcNNCgaN4J5cVF51AGkdNO/Ijm0SG5kGlR6alhZo3bx7ncKSR2rVrx5QpU4BolScl5OIXNXmcKfmTFF8DWh/42gk3LRQ4indyWXHRCaRx1LQjP7JJZGgeVHpkWlqo8nKzXr16xTYmabilS5dSVlbGcccdF+l+lJCLX9TkcS7Xlc6lynPH2WefnfBI/KPGQfmlwFG8k8uKi04gjaOmHfmRTSJDlyClR6alhd566y1AgaPvKue5RV3HUQm5+EVNHudyXelcWrhwIaDAsTa+dsJNCwWO4p1cVlx0AmkcNe3Ij2wSGZoHlR6Zlha66qqrYhqJRDF8+HBWrFjB8uXLI017UEIuflGTx7lcVzqXWrZsmfQQvOVrJ9y0UOAo3sllxUUnkMZR0478yCaRoXlQ6ZFpaaGioqI4hyONdPjhhzN58mQg2rQHJeTiFzV5nMt1pXPJ14DWB752wk0LBY7inVxWXHQCaRw17ciPbBIZmgeVHpmWFpo3bx4AvXv3jmlE0hhLliyhrKwscgJSCbn4RU0e53Jd6VyqvMx94MCBCY/EP2oclF8KHMU7uay46ATSOGrakR/ZJDI0Dyo9Mi0tpMCxaaic5xZ1jqMScvGLmjzO5brSufTuu+8CChxr42sn3LRQ4CjeyWXFRSeQxlHTjvzIJpGheVDpkWlpIV0J0TSMGDGCZcuWsXTp0khVRyXk4hc1eZzLdaVzSXMc6+ZrJ9y0UOAo3sllxUUnkMZR0478yCaRoXlQ6ZHLpYUkOa1bt65KpkUJ9pWQi1/U5HEu15XOJV8DWh/42gk3LRQ4indyWXHRCaRx1LQjP7JJZGgeVHpkWlpozpw5APTt2ze2MUnDLV68mJ49e0b+O6KEXPyiJo99Tf68/vrrAJx77rkJj8Q/ahyUXwocxTu5rLjoBNI4mnuVH9kkMjQPKj0yLS30zjvvAAocfVc5zy3q66SEXPyiJo9zua50Lr3//vuAAsfa+NoJNy0UOIp3cllx0QmkcRQ45kc2iQzNg0qPTEsLjR49OqaRSBQjR47kgw8+YMmSJZGqjjqvxi9q8jiX60rnkuY41s3XTrhpocBRvJPLiotOII2jph35kU0iQ/Og0iOXSwtJcg455JCqy4oVODYtUZPHuVxXOpd8DWh94Gsn3LRQ4CjeyWXFRScQ8Uk2iQzNg0qPTEsLzZo1C4CTTz45tjFJwy1atIiysjK6du0a6X6UkItf1OSxr8mfys825513XsIj8Y8aB+WXAkfxTi4rLjqBNI6aduRHNokMzYNKj0xLC1U23lDg6LfKeW5R13GU+EVNHudyXelcqmz6I/vztRNuWihwFO/ksuKiE0jjqGlHfmSTyNDlbOmRaWmhK6+8MqaRSBSXXnop//jHP1i8eHGkq1eUkItf1ORxLteVzqVcLluWNr52wk0LBY7inVxWXHQCaRw17ciPbBIZChzTI5dLC0lyWrVqVRX0RQkclZCLX9Tksa8Bmq8BrQ987YSbFgocxTu5/OCsE4j4JJtEhuZBpUempYUq12Lr379/bGOShquoqKCsrIwTTzwx0v0oIRe/qMljX5M/06dPB2DIkCHJDsRDahyUX3kLHM2sBfAKcFD4OJOcc983s87ARKAtMAe4yjn3iZkdBIwH+gIbgMucc0vD+/oOcC2wB/iac+6v4fYhwC+BIuAPzrm7w+21Pka+nqvkVi4DR51AGkdNO/JDiYzCkmlpoQ8++ABQ4Oi7ynlumuPY9EQ95+ZyXelcWrp0adJD8JavnXDTIp8Vx13AOc65bWbWHJhhZs8C3wT+xzk30czuJQgIfxv+v8k5V2JmlwM/AS4zsx7A5UApcCzwgpmdED7Gr4HBwApglpk95ZxbGN62tseQJiCXFRedQBpHTTvyI5tEhuZBpUempYWuuOKKmEYiUVx++eW89957LFq0KFLSRwm5+EVNHudyXelc8vUSWh/42gk3LfIWODrnHLAt/LZ5+M8B5wBfCLc/ANxJENQND78GmASMMzMLt090zu0CPjCzxcBnw/0WO+eWAJjZRGC4mVXU8xhSYHQCaRw17ciPbBIZmgeVHrlcWkiS06JFi6pu31ECRyXk4hc1eZzLdaVzydeA1ge+dsJNi7zOcTSzIoJLRUsIqoP/ADY75z4Nd1kBdAi/7gAsB3DOfWpmWwguNe0AvF7tbqvfZnmN7aeEt6nrMaQJyGXFRScQ8Uk2iQzNg0qPTEsLvfbaawCcdtppsY1JGm7BggWUlpZSWloa6X6UkItf1OSxr8mf5557DoALL7ww4ZH4R42D8iuvgaNzbg/Q28zaAE8A0WaW55iZXQ9cD3DcccclPBqplMuKi04gjaOmHfmhREZhybS00PLly+v9ufihcp6bKoVNT9Rzbi7Xlc4lnTvqpst48yuWrqrOuc1mNg04FWhjZs3CimBHYGW420qgE7DCzJoBrQma5FRur1T9NrVt31DPY9Qc1++A3wH069fPRX6ikhO5rLjoBNI4atqRH9kkMjQPKj0yLS102WWXxTQSiWLUqFFUVFRQUVERKemjhFz8oiaPc7mudC7ps03dfO2Emxb57Kp6JLA7DBpbEjSx+QkwDRhJ0PX0i8Dk8CZPhd/PDH/+knPOmdlTwJ/M7OcEzXG6Am8CBnQNO6iuJGig84XwNnU9hhQYnUAaR0078iObP/aaB5UeWpMzHZo3b15VeYoSOCohF7+oAVYu15XOJV8DWh/42gk3LfJZcTwGeCCc53gA8KhzboqZLQQmmtkPgbnAfeH+9wEPhs1vNhIEgjjn3jGzR4GFwKfATeElsJjZWOCvBMtx/NE59054X7fV8RjSBOSy4qITiPgkm0SG5kGlR6bAccaMGQCcccYZMY1IGmP+/PmUlpbSs2fPSPejhFz8oiaPfU3+PPPMMwBcdNFFCY/EP2oclF/57Ko6Hziplu1L+FdX1OrbdwKfr+O+fgT8qJbtzwDPZPsY0jTksuKiE0jjqGlHfiiRUVgyLS20evXqeAYikVRWG3UVQNMT9Zzra+C4cmWtM7AEfzvhpkUscxxFGiKXFRedQBpHE+/zI5tEhuZBFY6RI0cmPQTJwlVXXcWCBQtYsGBBpKSPEnLxi5o8zuW60rmkOY5187UTbloocJRU0wmkcdS0Iz+ySWRoHlR65HJpIUlOUVERc+fOBaJdLaCEXPzSmjz2NaD1ga+dcNNCgaN4J5cVF51AxCfZJDI0Dyo9Mi0t9PLLLwNw9tlnxzYmabh58+ZRWloa+XJFJeTiFzV57Gvy58knnwTgkksuSXQcPlLjoPxS4CjeyWXFRSeQxlHTjvxQIqOwZFpaaMOGDTGNRKKonOemOY5NT9Rzbi7Xlc6lNWvWJD0Eb/naCTctFDiKd3JZcdEJpHHUtCM/sklkaB5U4fjc5z6X9BAkC2PGjGH+/PnMnz8/UtJHCbn4RU0e53Jd6Vxq2bJl0kPwlq8NjdJCgaOkmk4gjaOmHfmRTSJD86DSI5dLC0mycnG1gBJy8Utr8tjXgNYH+tyXXwocxTu5rLjoBCI+yeb9qHlQ6ZFpaaFp06YBMHDgwNjGJA03Z84cevToEflyRSXk4hf1M4CvyZ/HH38c0FULtVHjoPxS4CjeyWXFpSS8NAidSBpETTvyQ4mMwpJpaaEtW7bENBKJonKe22c/q+Whm5qo59xcriudS+vWrUt6CFKgFDiKd1RxSZ6aduRHNplQzYMqHOqI2DSMHj2aefPmMW/evEhJHyXk4he1+pTLdaVz6eCDD056CN7ytRNuWihwFJH96PKX5GgeVHrkcmkhSVYurhZQQk5yxdeA1ge+dsJNCwWO4h1VXCStssmEah5UemRaWuiFF14A4Nxzz41tTNJws2bNorS0NPLlikrIxS9q9cnX5M+kSZMA/b2ojRoH5ZcCR/GOKi7JU9OO/FAmtLBkWlpox44dMY1EovB1nptkFvWcm8t1pXNJ1WtJigJH8Y4yaMlT0478yCYTqnlQheOiiy5KegiShSuvvJI5c+YwZ86cSEkfJeTiF7X6lMt1pXPpsMMOS3oI3vK1E25aKHAUkf2oaUdylElOj1wuLSTJysXVAkrISa74GtD6QFcI5JcCR/GOKi6SVtlkQjUPKj0yLS303HPPAXDeeefFMRxppNdff50TTjgh8uWKSsjFL2r1ydfkzyOPPAKoC31t1DgovxQ4indUcUmemnbkhzKhhSXTh7rdu3fHNBKJwtd5bpJZ1HNuLteVzqXNmzcnPQQpUFkFjmbW1jmnT/MSC1VckqemHfmRTSZU86AKx4UXXpj0ECQLV1xxBbNmzWLWrFmRkj5KyMUvavXJ14pemzZtkh6Ct3zthJsW2VYcXzezecD9wLPOOZe/IYlI0tS0IzmaB5UeWlooPXJxtYAScpIrvga0PtAVAvmVbeB4AnAucA3wKzN7FCh3zr2Xt5FJwVLFRdIqm0yo5kGlR6alhaZOnQrAkCFD4hiONNJrr71Gly5dIs9zU0IuflGrT74mfx5++GFATXJqo2OSX1kFjmGF8XngeTMbCDwEfMXM/g7c7pybmccxSoFRxSV5atqRH8qEFhYtLZQOvs5zk8yinnN9XVd669atSQ9BClTWcxyBK4GrgDXAV4GngN7AY0DnPI1PCpAqLslT0478yCYTqnlQhUOVxqbhsssu4/XXX+f111+PlPRRQi5+UatPviZ/2rZtm/QQvOVrJ9y0yPZS1ZnAg8AlzrkV1bbPNrN7cz8sEUmSmnYkR/Og0kNLC6VHLq4WUEJOcsXXgNYHukIgv7INHP/DOfdo9Q1m9nnn3GPOuZ/kYVxSwFRxkbTKJhOqeVDpkWlpob/85S+AEjW+mzFjBp06dYo8z02vc/yiVp98Tf489NBDgNYsrI0aB+VXtoHj7cCjNbZ9h+AyVZGcUsUleWrakR/KhBaWTEsLNW/ePKaRSBS+znOTzKKec31dV/rjjz9OeghSoOoNHM3sAmAo0MHMflXtR4cBn+ZzYFK4VHGRtMomE6p5UIVDr3HTMHLkSF577TVee+21SPOmlJCLX9Tqk6/rSh955JFJD8FbvnbCTYtMFccPgdnAxcCcats/Ar6Rr0GJSLL0wSY5mgeVHlpaKD10tYD4xNeA1ge6QiC/6g0cnXN/B/5uZhOcc6owSixUcZG0yiYTqnlQ6ZFpaaGnn34a0FUWvnv55Zc5+uijI89zU0IuflGrT74mf8aPHw/A6NGjEx6Jf9Q4KL8yXar6qHPuUmCumbnqPyJY3rFXXkcnBUkVl+SpaUd+KBNaWDItLdSyZct4BiKR+DrPTTKLes71dV1p9YKQpGS6VPXm8P9h+R6ISCUFK8lT0478yCYTqnlQhUOdo5uGz33uc8yYMYMZM2ZEmjelhFz8olaffF1X+qijjkp6CN7ytRNuWmS6VHVV+OV6YIdzbq+ZnQCcCDyb78GJSDJ0mbBIdFpaKD1ycbWAEnKSK74GtD7QFQL5le1yHK8AZ5rZ4cBzwCzgMmBUvgYmhUsVF0mrbDKhet+nR6bLyZ588klAHwJ9N23aNNq2bRt5npsScvGLWn3yNflTXl4OwJgxYxIdh4/UOCi/sg0czTm33cyuBX7jnPupmc3L47hEJEFq2pEfyoQWlky/P61bt45pJBKFr/PcJLOo51xf5xLu3Lkz6SFIgco6cDSzUwkqjNeG24ryMyQpdKq4JE9NO/Ijm0yo5kEVDt86NUrtLrnkEl5++WVefvnlSPOmlJCLX9Tqk6+vVYcOHZIegrd87YSbFtkGjjcD3wGecM69Y2ZdgGn5G5aIJMm3y3IKieZBpYeWFkqPXFwtoISc5MrQoUOTHoK3dIVAfmUVODrnXiGY51j5/RLga/kalBQ2VVwkrbLJhCrISI9MSws9/vjjgObk+O6FF17gsMMOi5xQU0IuflGrT74mfx588EFAcxxroznj+ZVV4Bh2Ur0FKK5+G+fcOfkZlhQyVVySp6Yd+aFMaGHJlPxq27ZtTCORKHyd5yaZRT3n+rqutOY4SlKyvVT1MeBe4A/AnvwNR8S/zF4hUtOO/MgmENc8qMKhdcaahosuuohp06Yxbdq0SPOmlJCLX9Rj7euVT506dUp6CN7ytRNuWmQbOH7qnPttXkciIt7QpPLkaB5UemhpofTIxdUCSshJrijBXjddIZBf2QaOT5vZV4AngF2VG51zG/MyKiloqrhIWmWTCVWWtHBMmjQJgJEjRyY8EqnPc889R6tWrSJ/WFdCLn5Rq0++Jn8mTJgAaI5jbfTZMb+yDRy/GP5/a7VtDuiS2+GIqOLiAzXtyA9lQgtLpg+bRx99dEwjkSh8necmmaX1nKs5jpKUbLuqds73QEQqqeKSPDXtyI9sMqGaB1U4zjjjjKSHIFm48MILeeGFF3jhhRci/X1SQi5+UatPvlUaKxUXFyc9BG/52gk3LbLtqtoK+CZwnHPuejPrCnRzzk3J6+hEJBFq2pEczYNKDy0tlB65qFwpISe5MmDAgKSH4C1dIZBf2V6qej8wBzgt/H4lQadVBY6Sc6q4SFplkwnVPKj0yLS00COPPALAZZddFsdwpJGmTp1K8+bNI1eflJCLX9Tqk6/Jn4kTJwKa41gb316rtMk2cDzeOXeZmV0B4JzbbmaWx3FJAVPFJXlq2pEfyoQWlkwfVtVSXyS/op5zfV1XWnMcJSnZBo6fmFlLgoY4mNnxVOuuKpJLqrgkT0078iObTKjmQRWO0047LfNOkrghQ4bw3HPP8dxzz0WaN6WEXPyiVp98nSfXpYt6U9bF1064aZFt4HgnMBXoZGYTgNOBq/M1KBFJlpp2JEfzoNJDSwulRy6uFlBCTnJFf6MlKdl2VX3OzOYA/QEDbnbOrc/ryKRgqeIiaZVNJlTzoNIj09JCDz/8MABXXHFFHMORRsrVPDd92I9f1OqTr8mfRx99FNAcx9qo0phf2XZVfdE5Nwj4Sy3bRHJKFZfkqWmHSHSZlm7o3FkrXTUFvs5zk/zzdV3ptK5PKf6rN3A0sxZAK6CdmR1OUG0EOAzokOexSYFSxSV5atqRH9lkQjUPqnD0798/6SFIFs477zymTp3K1KlTI1UzlJCLX9Tqk6/rSnft2jXpIXjL1064aZGp4ngD8HXgWILlOCoDx63AuPwNS0SSpKYdydE8qPTQ0kJSnRJykitKOtVNVwjkV72Bo3Pul8Avzeyrzrn/bcgdm1knYDxwFEE31t85535pZkcAjwDFwFLgUufcpnB5j18CQ4HtwBjn3FvhfX0R+I/wrn/onHsg3N4XKAdaAs8QzL10dT1GQ8YvyVHFRdIqm0yo5kGlR6alhR566CEArrzyyjiGI42Uq3luSsjFL2r1ydfkT+XnJM1x3J+vnXDTItvmOP9rZqcRBGLNqm0fX8/NPgW+5Zx7y8wOBeaY2fPAGOBF59zdZnY7cDtwG3AB0DX8dwrwW+CUMAj8PtCPIACdY2ZPhYHgb4EvAW8QBI5DgGfD+6ztMaQJUMUleWrakR/KhBaWTEsLdevWLaaRSBS+znOTzKKec31dV1pzHCUp2TbHeRA4HpgH7Ak3O4KKYq2cc6uAVeHXH5lZBcG8yOHAgHC3B4DpBEHdcGC8c84Br5tZGzM7Jtz3eefcxnAszwNDzGw6cJhz7vVw+3jgEoLAsa7HkCZAFZfkqWlHfmSTCdU8qMJx8sknJz0EycK5557LX/7yF/7yl79EmjelhFz8olaffF1X+sQTT0x6CN7ytRNuWmS7jmM/oEcY1DWYmRUDJxFUBo8Kg0qA1QSXskIQVC6vdrMV4bb6tq+oZTv1PIbUo7y8HNClD6L5E0nSPKj00NJC6ZGLqwWUkJNc6dOnT9JD8JauEMivbAPHBcDRhBXEhjCzQ4A/A193zm0NpjIGwvmIjQpGs1XfY5jZ9cD1AMcdd1w+hyENoIqLpFU2mVDNg0qPTEsLjR8fXLQzevToOIYjjZSreW5KyMUvavXJ1+TPE088ASjRXxtfO+GmRbaBYztgoZm9Ceyq3Oicu7i+G5lZc4KgcYJz7vFw8xozO8Y5tyq8FHVtuH0lUD3V3jHctpJ/XXZauX16uL1jLfvX9xj7cM79DvgdQL9+/fIawEr2VHFJnpp25IcyoYUl09JCpaWlMY1EovB1nptkFvWc6+u60tu3b096CFKgsg0c72zoHYddUu8DKpxzP6/2o6eALwJ3h/9PrrZ9rJlNJGiOsyUM/P4K/He4jiTAecB3nHMbzWyrmfUnuAR2NPC/GR5DmgBVXJKnph35kU0mVPOgCkffvn2THoJkYeDAgTz99NM8/fTTkeZNKSEXv6jVJ1/XlVbSqW6+dsJNi2y7qr7ciPs+HbgKeNvM5oXbvksQzD1qZtcCy4BLw589Q7AUx2KC5TiuDh97o5n9FzAr3O+uykY5wFf413Icz4b/qOcxRCQLatqRHM2DSg8tLZQeubhaQAk5yZVevXolPQRv6QqB/Ko3cDSzjwi6p+73I4Lpg4fVdVvn3Ixwv9oMqmV/B9xUx339EfhjLdtnA2W1bN9Q22NI06CKi6RVNplQzYNKj0xLC6khWdOQq3luSsjFL2r1ydfkz+TJwYV0Onfsz9dOuGlRb+DonDs0roGIVFLFJXlq2pEfyoQWlkxLC/Xu3TuegUgkvs5zk8yinnN9XVdacxwlKdnOcRSJjSouydP8ifzIJhOqeVCFQ4Fj03D22Wfz5JNP8uSTT0aaN6WEXPyiVp98XVdal6rWzddOuGmhwFFE9qOmHcnRPKj0yLS00J49ewAoKiqKbUzSOLm4WkAJOcmVE088MekheEtXCOSXAkfxjiouklbZZEI1Dyo9Mi0t9OCDDwKap+S7XM1zU0IuflGrT76uKz1lyhRA547a+NoJNy0UOIp3VHFJnpp25IcyoYUl09JCffr0iWkkEoWv89wks6jnXF/Xlf7444+THoIUKAWO4h1VXJKnuVf5kU0mVPOgCofmKTUNZ5xxBo8//jiPP/54pHlTSsjFL2r1ydd1pU866aSkh+AtXzvhpoUCRxHZjwLH5GgeVHpkWlpo9+7dADRv3jy2MUnj5OJqAZ1XJVdKSkqSHoK3dIVAfilwFO+o4pI8Ne3Ij2wyoZoHlR6ZlhaaMGECoAqU73I1z02BY/yiVp98XVf6mWeeAXTuqI2vnXDTQoGjeEcVl+SpaUd+KBNaWDItLdSvX7+YRiJR5GqemxJy8Yt6zvV1XWnNcZSkKHAU76jikjw17ciPbDKhmgdVOMrKypIegmThtNNOY9KkSUyaNCnSvCkl5OIXtfrk67rS+pxUN1874aaFAkcR2Y+adiRHl7OlR6alhXbu3AlAixYtYhuTNE4urhZQQk5ypbi4OOkheMvXTrhpocBRElFfVUUVl+SpaUd+ZJMJVeCYHpmWFpo4cSKgc53vcjXPTQm5+EWtPvm6rvTUqVMBnTtq42sn3LRQ4Cje0Qfn5KlpR35kkwnVPKj0yLS00CmnnBLTSCSKXM1zU0IuflGrT76uK71t27akhyAFSoGjeEeBY/LUtCM/ssmEah5U4ejevXvSQ5As9O/fn0ceeYRly5ZFmjelhFz8olaffF1XWkmnuvnaCTctFDiKd1RxSZ6adiRH86DSI9PSQtu3bwegVatWsY1JGicX86aUkJNcOfbYY5Megrd87YSbFgocxTuquCRPTTvyI5tMqOZBpUempYUeffRRQOc63+VqnpsScvGLWn3ydV3p559/HtC5oza+dsJNCwWO4h1VXJKnph35kU0mVPOg0iNTy/xTTz01ppFIFLma56aEXPyiVp98XVdacxwlKQocxTuquCRP8yfyI5tMqOZBFQ5fG2/Ivk4++WQefvhhFi9eHGnelBJy8YtaffJ1vUQlnermayfctFDgKN5RxSV5atqRHM2DSo9MSwtVVg0OOeSQmEYkjZWLeVNKyEmutG/fPukheEsJufxS4CjeUcUleWrakR/ZZEI1Dyo9MnWInjRpEqBzne9yNc9NCbn4Ra0++bqu9Isvvgj4Ny4f+NoJNy0UOIp3VHFJnpp25Ec2mVDNg0qPTIHjGWecEc9AJJJczXNTQi5+UatPvi4P9tFHHyU9BClQChzFO6q4JE/zJ/Ijm0yo5kGlR6alhUpKSuIcjjRS3759eeihh6ioqIg0b0oJufhFrT75GjieeeaZSQ/BW752wk0LBY7iHVVckqc5AsnRPKj0yLS00JYtWwBo3bp1XEOSRsrFOVEJuabH13Wl27Rpk/QQvOVrJ9y0UOAo3lHFJXlq2pEf2WRCNQ8qPTItLfTEE08AOtf5Llfz3JSQi1/U6pOv60q/9NJLAFxzzTUJj8Q/vnbCTQsFjuIdVVySp6Yd+ZFNJlTzoNIj09JCZ511VkwjkShydbmiEnLxi1p98nVd6R07diQ9BClQChzFO6q4JE9NO/Ijm0yo5kGlR6alhbp06RLncKSRevfuzfjx45k/f36keVNKyMUvavXJ13WlNcexbr52wk0LBY7iHVVckqemHcnRPKj0yLS00KZNmwA4/PDD4xqSNMKePXs48cQTI89zU0Ku6fF1XWlVrevma0OjtFDgKN5RxSV5atqRH9lkQjUPKj0yLS00efJkQOc63+VqnpsScvGLWn3ydV3padOmAbpqoTYKHPNLgaN4RxWX5KlpR35k8wdN86DSI9PSQgMGDIhnIBJJrua5KSEXv6hBhK/rSu/atSvpIXjL1064aaHAUbyjikvy1LQjP7L5EKN5UOmRaWmh4uLiGEcjjdWrVy/Ky8t56623Iv1eKiEXv6iBo6/rSutvdN187YSbFgocxTuquCRPl7/kRzaZUM2DSo9MSwutX78egHbt2sU1JGmE3bt3U1paGnmemz7sxy9q9cnXdaV9G49PfO2EmxYKHMU7qrgkT0078iObTKjmQaVHpqWFpkyZAuhc57tczXNTQi5+UatPvq4rXTnHUX8v9udrJ9y0UOAo3lHFJXlq2pEf2WRCNQ8qPTItLTRo0KCYRiJR9OvXjz179rBnz55I86aUkItf1OqTr+tKV1ZSZX++dsJNCwWO4h1l0JKnph35kU0mVPOg0iPT0kKdOnWKczjSSGVlZZSXlzN37txIv5dKyMUvavXJ13Wlzz777KSH4C1fO+GmhQJH8Y4qLslT0478yCYTqnlQ6ZFpaaG1a9cC0L59+7iGJI2wc+dOSktLOeiggyLdjxJy8YtaffJ1XekDDjgg6SF4y9dOuGmhwFG8o4pL8tS0Iz+yyYRqHlR6ZFpa6JlnngF0rvNdrua5KSEXv6jVJ1/XlZ4+fTqgLvS18bUTbloocBTvqOKSPDXtyI9sMqGaB5UemT7UDR48OKaRSBSnnHIKu3fvZvfu3ZHmTSkhF7+o1SetK930+NoJNy0UOIp3VHFJnpp25Ec2mVDNg0qPTEsLdejQIc7hSCN17949J+s4KiEXv6jVJ18rerrsuW6+dsJNCwWO4h1VXJKnph35kU0mVB8I0iPT0kKrV68G4Oijj45rSNII27dvp7S0lJYtW0a6HyXk4he1+uTrutJ79+5Negje8rUTbloocBTvqOKSPDXtyI9sMqGaB5UemZYWmjp1KqBzne9yNc9NCbn4Ra0++bqu9Msvvwz42/U1STom+aXAUbyjikvy1LQjP7LJhGoeVHpkWlpoyJAhMY1Eojj11FPZtWsXO3fujDRvSgm5+EWtPvm6rnSU9UTTztdOuGmhwFG8o4pL8tS0Iz+yyYRqHlR6ZFpaSJeoNg3dunXLyRxHJeTiF7X65Ou60gMHDkx6CN7ytRNuWihwFO+o4pI8Ne3Ij2wyoZoHlR6ZlhZauXIloN83323bto2ysjIOPvjgSPejhFz8olaffF1XunLupuxPnXDzS4GjeEcVl+SpaUd+ZJMJ1Tyo9Mi0tNDzzz8P6Fznu1zNc1OCIH5Rq0++riv9yiuvAFqzsDa+dsJNCwWO4h1VXJKnph35kU0mVPOg0iPT0kJDhw6NaSQSxRlnnMGOHTvYvn17pHlTSsjFL2r1ydd1pQ888MCkh+AtXzvhpoUCR/GOKi7JU9OO/MgmE6p5UOmRaWkhJQeahpKSEsrLy5kzZ06k30sl5OIXtfrk67rS55xzTtJD8JavnXDTQoGjeEcVl+QpI54f2WRCNQ8qPTItLbR8+XJAyTLfbdmyhbKyMg499NBI96OEXPyiVp98XVe68nnJ/nzthJsWChzFO6q4JE9NO/Ijm0yojnl6ZFpa6MUXXwR0rvNdrua5KSEXv6jVJ1/XlX711VcB6NWrV8Ij8Y+vnXDTQoGjeEcVl+SpaUd+ZJMJ1Tyo9Mi0tNCwYcPiGYhEctZZZ/Hxxx+zbdu2SPOmlJCLX9Tqk6/rSrds2TLpIXjL1064aaHAUbyjP6rJU9OO/MgmE6p5UOmRaWkhLTnUNHTp0iUncxyVkItf1OqTr+tKa45j3XzthJsWeQsczeyPwDBgrXOuLNx2BPAIUAwsBS51zm0yMwN+CQwFtgNjnHNvhbf5IvAf4d3+0Dn3QLi9L1AOtASeAW52zrm6HiNfz1NyTxWX5Gl+aX5kkwnVPKj0yLS00NKlSwF/P5xKYNOmTfTs2TNyBUMJufhFrT75uq705s2bkx6Ct3zthJsWB+TxvsuBmp+AbgdedM51BV4Mvwe4AOga/rse+C1UBZrfB04BPgt838wqZyj/FvhStdsNyfAY0kRMnTq1quoiyVi+fHlV4w7JnSeeeKIqG1qXo48+WkmTlBg0aFC9ywtNnz6d6dOnxzcgaZTJkyfz9ttvR65etW/fXkm5mGVzzq3PlClTqhJAPnn11Ver5jnKvrp06eJtN9w0yFvF0Tn3ipkV19g8HBgQfv0AMB24Ldw+3jnngNfNrI2ZHRPu+7xzbiOAmT0PDDGz6cBhzrnXw+3jgUuAZ+t5DGkiVHFJnpp25Ec2mVDNg0qPTN1Shw8fHtNIJIoBAwawdetWtmzZEqnqqC668YtaffJ1XemDH3ss+OKrX012IB7ytRNuWsQ9x/Eo59yq8OvVwFHh1x2A6uWNFeG2+ravqGV7fY8hTYSqLclT0478yCYLqnlQ6ZFpaSF9sGkaiouLKS8v56233or0e6mEXPyiVp58DfKPPfbYpIfgLV874aZFYs1xwvmILsnHMLPrCS6N5bjjjsvnUKQBVHFJnm/zOdIim0yo5kGlR6alhZYsWQL4u8i4BNavX09ZWRlHHHFEpPtRQi5+UatPvq4rHaW7b9r52gk3LfI5x7E2a8JLUAn/XxtuXwlUT+t0DLfVt71jLdvre4z9OOd+55zr55zrd+SRRzb6SUluPf/881VVF0nG0qVLqxp3SO5Mnjy5KhtaF82DSo/BgwfXu7zQK6+8wiuvvBLjiKQxpkyZwoIFCyIH+O3atVNSLmbZnHPr88wzz1QlgHyyY8cOduzYEfl+3nvvPcrLy6MPyCPFxcVqOJZHcVccnwK+CNwd/j+52vaxZjaRoBHOFufcKjP7K/Df1RrinAd8xzm30cy2mll/4A1gNPC/GR5DmghVXJJX2bBDl3rkVjaZUM2DSo9MV02MGDEippFIFIMGDWLz5s1s2rQp0uXF6qIbv6jVJ1/XlVbzurr52gk3LfK5HMfDBE1q2pnZCoLuqHcDj5rZtcAy4NJw92cIluJYTLAcx9UAYYD4X8CscL+7KhvlAF/hX8txPBv+o57HkCZC1ZbkqWlHfmTzgVHzoNIj09JCWqC6aejUqRMvvvhi5HUclZCLX9Qg3dcpM76OyweZlkGSaPLZVfWKOn60X4uqsJvqTXXczx+BP9ayfTZQVsv2DbU9hjQdqrgkT0078iObTKjmQaVH5bJCdX2AWbx4MRB9kXLJr7Vr11JWVha5gqGEXPyiVp98XVf64IMPTnoI3vK1E25aJNYcR6QuqrgkT0078iObTKgur0mPTEsLzZgxA1Dg6LtMTY6ypYRc/KJWnzIlf5Kyffv2pIfgLRUd8kuBo3hHFZfkVTbsUOCYW9lkQjUPKj0yVSlGjhwZ00gkisGDB7Nx40bWr18fKbGjhFz8olaffF1XesWKFZl3KlC+dsJNCwWO4h1VXJKnph35kU0mVPOg0iPT0kJqqd80dOjQISfrqyohF7+o1SffLlGt1LFjx8w7FahcXSEgtVPgKN5RxSV5atqRH9lkQjUPKj0yBRuLFi0CoFu3bnENSRph9erV9OzZM3IFQwm5+EWtPvm6rnSrVq2SHoK3fO2EmxYKHMU7qrgkT0078iObTKjmQaVHpqWFZs6cCShw9F2u5rkpIRe/qNWnXFSa8+Hjjz9Oegje8i3ITxsFjuIdVVySp6Yd+ZFNJlTzoNIjU5Xj0ku1WlRTMGTIENavX8/atWsjVR2VkItf1OqTr+tKV1ZCZX++dsJNCwWO4h1VXJKnph35kU0mVPOg0iPT0kK63KxpOProo3NSdVRCLn5Rq0++NlhR59C6+doJNy0UOIp3VHFJnpp25Ec2mVDNg0qPTEsLVVRUANC9e/e4hiSNsHLlSsrKyjjmmGMi3Y8ScvGLWn3ydV3pli1bJj0Eb/naCTctFDiKd1RxSZ6aduRHNplQzYNKj0xLC73xxhuAAkffVc5z69evX6T7UUIuflGrT76uK71t27akh+AtXaKaXwocxTuquCRPTTvyI5tMqOZBpUempYUuv/zymEYiUQwdOpS1a9eyevXqSB9KlZCLX9Tqk6/rSn/44YdJD8FbvnbCTQsFjuIdVVySp6Yd+ZHNh07Ng0qPTEsLtWjRIr7BSKO1b98+J2vDKSEXv6jVJ1/Xle503HFJD8FbvnbCTQsFjuIdVVySp6Yd+ZFNJlTzoNIj09JCCxYsAKCsrCymEUljLF++nJ49e0auYCghF7+o1Sdf15VuqaRTnXzthJsWChzFO6q4JE9NO/Ijm0yo5kGlR6alhWbPng0ocPRd5Ty3vn37RrofJeTiF7X65Ou60h999FHSQ/CWr51w00KBo3hHFZfkqWlHfmSTCdU8qPTItLTQqFGjYhqJRDFs2DBWrVrFypUrI1UdlZCLX9Tqk6/rSq9atSrpIXjL1064aaHAUbyjikvy1LQjP7LJhGoeVHpkWlqoefPmcQ5HGqldu3ZMmTIFiFZ5UkIuflGrT76uK/2Zz3wm6SF4y9dOuGmhwFG8o4pL8tS0Iz+yyYRqHlR6ZFpaaP78+QD06tUrtjFJwy1dupSysjKOi9iQRAm5+EWtPvm6rvRBBx2U9BC85Wsn3LRQ4CjeUcUleWrakR/ZZEI1Dyo9Mi0t9NZbbwEKHH1XOc8t6jqOSsjFL2r1ydd1pbdu3Zr0ELzlayfctFDgKN5RxSV5atqRH9lkQjUPKj0yLS101VVXxTQSiWL48OGsWLGC5cuXR5o3pYRc/KJWn3xdV3r16tVJD8FbvnbCTQsFjuIdVVySp6Yd+ZFNJlTzoNIj09JCRUVFcQ5HGunwww9n8uTJQLR5U0rIxS9q9cnXdaU1x7FuvnbCTQsFjuIdVVySp6Yd+ZFNJlTzoNIj09JC8+bNA6B3794xjUgaY8mSJZSVlUWuYCghF7+o1Sdf15XWHMe6+doJNy0UOIp3VHFJnpp25Ec2mVDNg0qPTEsLKXBsGirnuUWd46iEXPyiVp98XVd6y5YtSQ/BW752wk0LBY7iHVVckqemHfmRTSZU86DSI9PSQiXhh1J0SZXXRowYwbJly1i6dGmkqqMScvGLWn3ydV3pNWvWJD0Eb/naCTctFDiKd1RxSZ6aduRHNplQzYNKDy0tlA6tW7euSqZFmTelhFz8olaffF1XunPnzkkPwVu+dsJNCwWO4h1VXJKnph35kU0mVPOg0kNLC6XD4sWL6dmzZ+QPokrIxS9q9cnX5I8ue66br51w00KBo3hHFZfkae5VfmSTCdUHgvTQ0kLpUDnPrW/fvpHuRwm5+EWtPvma/Nm8eXPSQ/CWr51w00KBo3hHFZfkKXDMj2wyoZoHlR5aWigdRo4cyQcffMCSJUsiVR11Xo1f1OqTr8mftWvXJj0Eb/naCTctFDiKd1RxSZ6aduRHNplQzYNKDy0tlA6HHHIIc+bMAaLNm1LgGL+o1Sdfkz+a41g3XzvhpoUCR/GOKi6SVtlkQjUPKj20tFA6LFq0iLKyMrp27RrpfpSQi1/U6pOvyR8l2OvmayfctFDgKN5RxUXSKptMqOZBpYeWFkqHynluUddxlPhFrT75mvzZpDmOdfK1E25aKHAU76jiImmVTSZUl7Olh5YWSodLL72Uf/zjHyxevFiXvzUxUatPviZ/1mmOY5187YSbFgocxTuquEhaZZMJVeCYHlpaKB1atWpVNcdRgWPTErX65Gvyp8vxxyc9BG/52gk3LRQ4inf0wVnSKptMqOZBpYeWFkqHiooKysrKOPHEE5MeijRQ1OqTr8mfZkqw18nXTrhpocBRvKPAUdJKmdDCoqWF0qFynpvmODY9Uc+5viZ/Nm7cmPQQvOVrJ9y0UOAo3lHFRdJKmdDCos6H6XD55Zfz3nvvsWjRIiV9mpio51xfkz/r169Pegje8rUTbloocBQRiYkyoYVFSwulQ4sWLaq6fStwbFqinnN9Tf4crzmOdfK1E25aKHAUEYmJMqGFRUsLpcOCBQsoLS2ltLQ06aFIA0U95/qa/FETwbr52gk3LRQ4iojERJnQwqKlhdKhcp7bySefnPBIpKGinnN9Tf5s2LiRfy5bxuLycsZoWs8+fO2EmxYKHEVEYqJMaGFRVSAdRo0aRUVFBRUVFUr6NDFRz7m+Jn82rF/PR9u2cWjSA/GQr51w00KBo4hITJQJLSzqEJ0OzZs3r6o8KXBsWqKec31N/pSUlOCcS3oYXvK1E25aKHAUEYmJMqGFRYFjOsyfP5/S0lJ69uyZ9FCkgaKec339HT7ggAMws6SH4SVfO+GmhQJHEZGYKBNaWLS0UDpUVhs1x7HpiXrO9TVw3LBhA9u2beOwpAfiIV874aaFAkdJvffee08TyMULyoRKJuXl5QA6X3nkqquuYsGCBSxYsEBJnyYm6jnX1+TPhg0b2Pbxxwoca+FrJ9y0UOAoIhITZUJFmp6ioiLmzp0L6GqBpiat59ySNWs4cssWNiQ9EA/52gk3LRQ4iojERJlQkaZn3rx5lJaWene5omSmc27h8bUTbloocBQRiYkyoSJNT+U8N81xbHp0zi08vnbCTQsFjpKYGeHcAc3lkUKhTKhI01MyYwYbNm5kfp8+CkCaGJ1zC4+vDY3S4oCkByB1Ky8vr2qUICJNX1FRkbKhIk3QhvXrq6pX0nTonFt45s2bVxU8Su4pcJR9zJgxQ8GqSJ7oD5pI01RSUqLqVROkc27hGUNwlYA+y+aHAkcRkZjoQ0zTEdcVH++9954+4DQBBxxwgCpXTZCP59zy8nKuu+46b37vVTCQhlDgKCISk5IZM/61LpgUvPfee4/Va9YkPQzJwoYNG7wLQCQznXML03vvvVfVR0NyK7WBo5kNMbNFZrbYzG5PejwiIpIu+apKqgLgHwWOUqjUbyM/mupxTWXgaGZFwK+BC4AewBVm1iPZUaWHj292H8cUVRqfk6/qO9Z6HfJLx1eagpI1a1S5ipnODSL+SWXgCHwWWOycW+Kc+wSYCAxPeEwiIiIiIiJNUloDxw7A8mrfrwi3iYiIiIiISAOZcy7pMeScmY0Ehjjnrgu/vwo4xTk3tsZ+1wPXh992AxbFOMx2wPoYH0/2peOfLB3/ZOn4J0vHP1k6/snS8U+Wjn9ymsqx/4xz7sjaftAs7pHEZCXQqdr3HcNt+3DO/Q74XVyDqs7MZjvn+iXx2KLjnzQd/2Tp+CdLxz9ZOv7J0vFPlo5/ctJw7NN6qeosoKuZdTazA4HLgacSHpOIiIiIiEiTlMqKo3PuUzMbC/wVKAL+6Jx7J+FhiYiIiIiINEmpDBwBnHPPAM8kPY56JHKJrFTR8U+Wjn+ydPyTpeOfLB3/ZOn4J0vHPzlN/tinsjmOiIiIiIiI5E5a5ziKiIiIiIhIjihwzBMz+6OZrTWzBdW2HWFmz5vZ++H/h4fbzcx+ZWaLzWy+mfVJbuRNn5l1MrNpZrbQzN4xs5vD7Tr+MTCzFmb2ppn9PTz+Pwi3dzazN8Lj/EjYuAozOyj8fnH48+JEn0BKmFmRmc01synh9zr+MTGzpWb2tpnNM7PZ4Tadf2JiZm3MbJKZvWtmFWZ2qo5/PMysW/i+r/y31cy+ruMfHzP7Rvi3d4GZPRz+Tdb5PyZmdnN47N8xs6+H21Lz/lfgmD/lwJAa224HXnTOdQVeDL8HuADoGv67HvhtTGNMq0+BbznnegD9gZvMrAc6/nHZBZzjnPs3oDcwxMz6Az8B/sc5VwJsAq4N978W2BRu/59wP4nuZqCi2vc6/vEa6JzrXa31us4/8fklMNU5dyLwbwS/Bzr+MXDOLQrf972BvsB24Al0/GNhZh2ArwH9nHNlBA0iL0fn/1iYWRnwJeCzBOeeYWZWQore/woc88Q59wqwscbm4cAD4dcPAJdU2z7eBV4H2pjZMbEMNIWcc6ucc2+FX39E8KGhAzr+sQiP47bw2+bhPwecA0wKt9c8/pWvyyRgkJlZPKNNJzPrCFwI/CH83tDxT5rOPzEws9bAWcB9AM65T5xzm9HxT8Ig4B/OuWXo+MepGdDSzJoBrYBV6Pwfl+7AG8657c65T4GXgc+Rove/Asd4HeWcWxV+vRo4Kvy6A7C82n4rwm0SUXjZxUnAG+j4xya8THIesBZ4HvgHsDk8kcK+x7jq+Ic/3wK0jXXA6fML4NvA3vD7tuj4x8kBz5nZHDO7Ptym8088OgPrgPvDS7X/YGYHo+OfhMuBh8Ovdfxj4JxbCdwD/JMgYNwCzEHn/7gsAM40s7Zm1goYCnQiRe9/BY4JcUE7W7W0zSMzOwT4M/B159zW6j/T8c8v59ye8FKljgSXbJyY7IgKh5kNA9Y65+YkPZYCdoZzrg/BZUg3mdlZ1X+o809eNQP6AL91zp0EfMy/LgsDdPzjEM6huxh4rObPdPzzJ5w7N5wggXIscDD7T5uSPHHOVRBc7vscMBWYB+ypsU+Tfv8rcIzXmsoSdPj/2nD7SoKMRKWO4TZpJDNrThA0TnDOPR5u1vGPWXiJ2DTgVIJLMCrXjq1+jKuOf/jz1sCGeEeaKqcDF5vZUmAiwSVKv0THPzZh1h/n3FqC+V2fReefuKwAVjjn3gi/n0QQSOr4x+sC4C3n3Jrwex3/eJwLfOCcW+ec2w08TvA3Qef/mDjn7nPO9XXOnUUwn/Q9UvT+V+AYr6eAL4ZffxGYXG376LC7Un9gS7WStjRQeH3+fUCFc+7n1X6k4x8DMzvSzNqEX7cEBhPMM50GjAx3q3n8K1+XkcBLTgvMNppz7jvOuY7OuWKCS8Vecs6NQsc/FmZ2sJkdWvk1cB7B5Us6/8TAObcaWG5m3cJNg4CF6PjH7Qr+dZkq6PjH5Z9AfzNrFX4Wqnz/6/wfEzNrH/5/HMH8xj+Rove/6f2RH2b2MDAAaAesAb4PPAk8ChwHLAMudc5tDH+5xxFcTrAduNo5NzuBYaeCmZ0BvAq8zb/meH2XYJ6jjn+emVkvgsnfRQTJqUedc3eZWReCCtgRwFzgSufcLjNrATxIMBd1I3C5c25JMqNPFzMbANzinBum4x+P8Dg/EX7bDPiTc+5HZtYWnX9iYWa9CRpDHQgsAa4mPBeh4593YcLkn0AX59yWcJve/zGxYAmsywg6zM8FriOYN6fzfwzM7FWCeaK7gW86515M0/tfgaOIiIiIiIjUS5eqioiIiIiISL0UOIqIiIiIiEi9FDiKiIiIiIhIvRQ4ioiIiIiISL0UOIqIiIiIiEi9FDiKiIh4xMyam9lbSY9DRESkOgWOIiIifjkD+FvSgxAREalOgaOIiEgMzKzYzN41s3Ize8/MJpjZuWb2NzN738w+G+46BHjWzG40s3nhvw/MbFqS4xcRkcJmzrmkxyAiIpJ6ZlYMLAZOAt4BZgF/B64FLgauds5dYmZvAgOcc9vD2zUHXgJ+6px7Oomxi4iIqOIoIiISnw+cc2875/YSBI8vuiCD+zZQbGYdgI2VQWPol8BLChpFRCRJzZIegIiISAHZVe3rvdW+30vwN3kI8NfKHcxsDPAZYGxM4xMREamVKo4iIiL+GAI8C2BmfYFbgCvDCqWIiEhiFDiKiIj4oQgocc69G34/FjgCmBY2yPlDckMTEZFCp+Y4IiIiHjCzMwiqizcmPRYREZGaFDiKiIiIiIhIvXSpqoiIiIiIiNRLgaOIiIiIiIjUS4GjiIiIiIiI1EuBo4iIiIiIiNRLgaOIiIiIiIjUS4GjiIiIiIiI1EuBo4iIiIiIiNTr/wPVmTwL1fGVkgAAAABJRU5ErkJggg==\n",
diff --git a/sandbox/gpu_performance_comparison.ipynb b/sandbox/gpu_performance_comparison.ipynb
new file mode 100644
index 00000000..b1bd5345
--- /dev/null
+++ b/sandbox/gpu_performance_comparison.ipynb
@@ -0,0 +1,415 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "c8ed5615",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# !pip install cupy-cuda111"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "11a337da",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "2021-06-04 13:46:44> Logging to C:\\Users\\admin\\alphapept\\logs\\log_20210604134644.txt.\n",
+      "2021-06-04 13:46:44> Platform information:\n",
+      "2021-06-04 13:46:44> system     - Windows\n",
+      "2021-06-04 13:46:44> release    - 10\n",
+      "2021-06-04 13:46:44> version    - 10.0.19041\n",
+      "2021-06-04 13:46:44> machine    - AMD64\n",
+      "2021-06-04 13:46:44> processor  - Intel64 Family 6 Model 85 Stepping 4, GenuineIntel\n",
+      "2021-06-04 13:46:44> cpu count  - 24\n",
+      "2021-06-04 13:46:44> ram memory - 115.3/127.7 Gb (available/total)\n",
+      "2021-06-04 13:46:44> \n",
+      "2021-06-04 13:46:44> Python information:\n",
+      "2021-06-04 13:46:44> SQLAlchemy     - 1.3.22\n",
+      "2021-06-04 13:46:44> alphapept      - 0.3.21\n",
+      "2021-06-04 13:46:44> biopython      - 1.78\n",
+      "2021-06-04 13:46:44> click          - 7.1.2\n",
+      "2021-06-04 13:46:44> comtypes       - 1.1.7\n",
+      "2021-06-04 13:46:44> fastcore       - 1.3.5\n",
+      "2021-06-04 13:46:44> h5py           - 3.1.0\n",
+      "2021-06-04 13:46:44> lxml           - 4.6.2\n",
+      "2021-06-04 13:46:44> matplotlib     - 3.3.3\n",
+      "2021-06-04 13:46:44> nbdev          - 1.1.4\n",
+      "2021-06-04 13:46:44> networkx       - 2.5\n",
+      "2021-06-04 13:46:44> numba          - 0.52.0\n",
+      "2021-06-04 13:46:44> numpy          - 1.19.4\n",
+      "2021-06-04 13:46:44> pandas         - 1.2.1\n",
+      "2021-06-04 13:46:44> plotly_express - 0.4.0\n",
+      "2021-06-04 13:46:44> psutil         - 5.8.0\n",
+      "2021-06-04 13:46:44> python         - 3.8.8\n",
+      "2021-06-04 13:46:44> pythonnet      - 2.5.1\n",
+      "2021-06-04 13:46:44> pyyaml         - 5.3.1\n",
+      "2021-06-04 13:46:44> scikit-learn   - 0.23.2\n",
+      "2021-06-04 13:46:44> scipy          - 1.6.0\n",
+      "2021-06-04 13:46:44> seaborn        - 0.11.1\n",
+      "2021-06-04 13:46:44> streamlit      - 0.80.0\n",
+      "2021-06-04 13:46:44> tables         - 3.6.1\n",
+      "2021-06-04 13:46:44> tqdm           - 4.56.0\n",
+      "2021-06-04 13:46:44> watchdog       - 2.1.0\n",
+      "2021-06-04 13:46:44> wget           - 3.2\n",
+      "2021-06-04 13:46:44> \n",
+      "2021-06-04 13:46:44> Check for settings not completely implemented yet.\n",
+      "2021-06-04 13:46:44> Checking if files exist.\n",
+      "2021-06-04 13:46:44> Results path was not set. Setting to D:\\willems\\data\\gpu_cpu_performance_comparison\\results.hdf\n",
+      "2021-06-04 13:46:44> Shortnames not set. Setting to filename.\n",
+      "2021-06-04 13:46:44> No database path set and save_db option checked. Using default path D:\\willems\\data\\gpu_cpu_performance_comparison\\database.hdf\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "\r",
+      "  0%|                                                                                            | 0/1 [00:00<?, ?it/s]"
+     ]
+    },
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "2021-06-04 13:46:44> Processing 1 files for step raw_conversion\n",
+      "2021-06-04 13:46:44> File D:\\willems\\data\\gpu_cpu_performance_comparison\\HeLa_500ng has extension .raw - converting from Thermo.\n",
+      "2021-06-04 13:48:58> File conversion complete. Extracted 120,226 precursors.\n",
+      "2021-06-04 13:49:04> File conversion of file D:\\willems\\data\\gpu_cpu_performance_comparison\\HeLa_500ng.raw complete.\n"
+     ]
+    }
+   ],
+   "source": [
+    "import alphapept.performance\n",
+    "import os\n",
+    "import time\n",
+    "import alphapept.settings\n",
+    "import alphapept.paths\n",
+    "import alphapept.interface\n",
+    "import sys\n",
+    "import shutil\n",
+    "import logging\n",
+    "import importlib\n",
+    "import alphapept.feature_finding\n",
+    "import numpy as np\n",
+    "\n",
+    "sample = \"HeLa_500ng.raw\"\n",
+    "original_file = f\"D:/willems/data/raw/{sample}\"\n",
+    "tmp_folder = 'D:\\willems\\data\\gpu_cpu_performance_comparison'\n",
+    "\n",
+    "if os.path.exists(tmp_folder):\n",
+    "    shutil.rmtree(tmp_folder)\n",
+    "\n",
+    "os.makedirs(tmp_folder)\n",
+    "shutil.copy(original_file, tmp_folder)\n",
+    "\n",
+    "file_name = os.path.join(tmp_folder, sample)\n",
+    "settings = alphapept.settings.load_settings(alphapept.paths.DEFAULT_SETTINGS_PATH)\n",
+    "settings['experiment']['file_paths'] = [file_name]\n",
+    "settings_ = alphapept.interface.import_raw_data(settings)\n",
+    "base, ext = os.path.splitext(file_name)\n",
+    "ms_file = alphapept.io.MS_Data_File(f\"{base}.ms_data.hdf\", is_read_only=True)\n",
+    "query_data = ms_file.read_DDA_query_data()\n",
+    "int_data = np.array(query_data['int_list_ms1'])\n",
+    "f_settings = settings['features']\n",
+    "max_gap = f_settings['max_gap']\n",
+    "ppm_tol = f_settings['ppm_tol']\n",
+    "split_level = f_settings['split_level']\n",
+    "window = f_settings['smoothing_window']\n",
+    "large_peak = f_settings['large_peak']\n",
+    "\n",
+    "min_charge = f_settings['min_charge']\n",
+    "max_charge = f_settings['max_charge']\n",
+    "seed_masses = f_settings['seed_masses']\n",
+    "\n",
+    "hill_nboot_max = f_settings['hill_nboot_max']\n",
+    "hill_nboot = f_settings['hill_nboot']\n",
+    "\n",
+    "mass_range = f_settings['mass_range']\n",
+    "\n",
+    "min_correlation = f_settings['min_correlation']"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "f26627ba",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import timeit\n",
+    "import time\n",
+    "\n",
+    "def timing_decorator(func, timings_dict, repeats=3, loops_per_repeat=1):\n",
+    "    def wrapper(*args, **kwargs):\n",
+    "        if func.__name__ not in timings_dict:\n",
+    "            timings_dict[func.__name__] = {}\n",
+    "        if alphapept.performance.COMPILATION_MODE in timings_dict[func.__name__]:\n",
+    "            logging.warning(\n",
+    "                f\"{func.__name__} is already timed for {alphapept.performance.COMPILATION_MODE}\"\n",
+    "            )\n",
+    "#         timings_dict[func.__name__][alphapept.performance.COMPILATION_MODE] = timeit.repeat(\n",
+    "#             lambda: func(*args, **kwargs),\n",
+    "#             number=loops_per_repeat,\n",
+    "#             repeat=repeats\n",
+    "#         )\n",
+    "#         return func(*args, **kwargs)\n",
+    "        start = time.time()\n",
+    "        results = func(*args, **kwargs)\n",
+    "        timings_dict[func.__name__][alphapept.performance.COMPILATION_MODE] = time.time() - start\n",
+    "        return results\n",
+    "    return wrapper"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "1bc8f789",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def time_feature_finding_functions(\n",
+    "    compilation_mode,\n",
+    "    timings_dict,\n",
+    "    repeats=3,\n",
+    "    loops_per_repeat=1\n",
+    "):\n",
+    "    print(f\"Running feature finding functions with compilation mode '{compilation_mode}'\")\n",
+    "    alphapept.performance.set_compilation_mode(compilation_mode)\n",
+    "    importlib.reload(alphapept.feature_finding)\n",
+    "    alphapept.feature_finding.connect_centroids_unidirection = timing_decorator(\n",
+    "        alphapept.feature_finding.connect_centroids_unidirection,\n",
+    "        timings_dict,\n",
+    "        repeats=3,\n",
+    "        loops_per_repeat=1\n",
+    "    )\n",
+    "    alphapept.feature_finding.convert_connections_to_array = timing_decorator(\n",
+    "        alphapept.feature_finding.convert_connections_to_array,\n",
+    "        timings_dict,\n",
+    "        repeats=3,\n",
+    "        loops_per_repeat=1\n",
+    "    )\n",
+    "    alphapept.feature_finding.eliminate_overarching_vertex = timing_decorator(\n",
+    "        alphapept.feature_finding.eliminate_overarching_vertex,\n",
+    "        timings_dict,\n",
+    "        repeats=3,\n",
+    "        loops_per_repeat=1\n",
+    "    )\n",
+    "    alphapept.feature_finding.path_finder = timing_decorator(\n",
+    "        alphapept.feature_finding.path_finder,\n",
+    "        timings_dict,\n",
+    "        repeats=3,\n",
+    "        loops_per_repeat=1\n",
+    "    )\n",
+    "    alphapept.feature_finding.find_path_start = timing_decorator(\n",
+    "        alphapept.feature_finding.find_path_start,\n",
+    "        timings_dict,\n",
+    "        repeats=3,\n",
+    "        loops_per_repeat=1\n",
+    "    )\n",
+    "    alphapept.feature_finding.find_path_length = timing_decorator(\n",
+    "        alphapept.feature_finding.find_path_length,\n",
+    "        timings_dict,\n",
+    "        repeats=3,\n",
+    "        loops_per_repeat=1\n",
+    "    )\n",
+    "    alphapept.feature_finding.fill_path_matrix = timing_decorator(\n",
+    "        alphapept.feature_finding.fill_path_matrix,\n",
+    "        timings_dict,\n",
+    "        repeats=3,\n",
+    "        loops_per_repeat=1\n",
+    "    )\n",
+    "        \n",
+    "    logging.info(f'Hill extraction with ppm_tol {ppm_tol} and max_gap {max_gap}')\n",
+    "    hill_ptrs, hill_data, path_node_cnt, score_median, score_std = alphapept.feature_finding.extract_hills(query_data, max_gap, ppm_tol)\n",
+    "    logging.info(f'Number of hills {len(hill_ptrs):,}, len = {np.mean(path_node_cnt):.2f}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "013eec48",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Running feature finding functions with compilation mode 'cuda'\n",
+      "2021-06-04 13:49:04> Hill extraction with ppm_tol 8 and max_gap 2\n",
+      "2021-06-04 13:49:07> Number of hills 1,105,353, len = 17.35\n",
+      "Running feature finding functions with compilation mode 'numba-multithread'\n",
+      "2021-06-04 13:49:07> Hill extraction with ppm_tol 8 and max_gap 2\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "  0%|                                                                                            | 0/1 [02:23<?, ?it/s]\n"
+     ]
+    },
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "2021-06-04 13:49:13> Number of hills 1,105,353, len = 17.35\n",
+      "Running feature finding functions with compilation mode 'numba'\n",
+      "2021-06-04 13:49:13> Hill extraction with ppm_tol 8 and max_gap 2\n",
+      "2021-06-04 13:49:21> Number of hills 1,105,353, len = 17.35\n",
+      "Running feature finding functions with compilation mode 'python'\n",
+      "2021-06-04 13:49:21> Hill extraction with ppm_tol 8 and max_gap 2\n",
+      "2021-06-04 13:55:00> Number of hills 1,105,353, len = 17.35\n"
+     ]
+    }
+   ],
+   "source": [
+    "timings_dict = {}\n",
+    "\n",
+    "time_feature_finding_functions(\"cuda\", timings_dict)\n",
+    "time_feature_finding_functions(\"numba-multithread\", timings_dict)\n",
+    "time_feature_finding_functions(\"numba\", timings_dict)\n",
+    "time_feature_finding_functions(\"python\", timings_dict)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "id": "552960c1",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 432x288 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "from matplotlib import pyplot as plt\n",
+    "include_python = True\n",
+    "for func in timings_dict:\n",
+    "    if not include_python:\n",
+    "        plt.bar(list(timings_dict[func].keys())[:-1], list(timings_dict[func].values())[:-1])\n",
+    "    else:\n",
+    "        plt.bar(list(timings_dict[func].keys()), list(timings_dict[func].values()))\n",
+    "    plt.yscale('log')\n",
+    "    plt.title(func)\n",
+    "    plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a40c1f45",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.8"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/setup.py b/setup.py
index 5a47da7e..30d0177b 100644
--- a/setup.py
+++ b/setup.py
@@ -70,7 +70,9 @@
     packages=setuptools.find_packages(),
     # TODO: Modifying this should allow to remove the MAINFEST.in
     include_package_data=True,
-    install_requires=requirements,
+    install_requires=requirements + [
+        "pywin32==225; sys_platform=='win32'"
+    ],
     python_requires=f'>={cfg["min_python"]},<{cfg["max_python"]}',
     long_description=long_description,
     long_description_content_type='text/markdown',


Test patch:
diff --git a/.github/workflows/gpu_test.yml b/.github/workflows/gpu_test.yml
index 3f4d2454..c0e13107 100644
--- a/.github/workflows/gpu_test.yml
+++ b/.github/workflows/gpu_test.yml
@@ -1,5 +1,5 @@
 # GitHub Actions to automatically test on sample files
-name: GPU Test
+name: GPU Test Cupy 11.1
 
 on:
   workflow_dispatch:
@@ -14,16 +14,21 @@ jobs:
         conda create --name alphapept_gpu python=3.8 -y
         conda activate alphapept_gpu
         pip install .
-        pip install -r requirements.txt
-        pip install cupy-cuda110
+        pip install .
         pip install pymongo
         pip install dnspython
         pip install wget
+        pip install cupy-cuda111
   GPU_Test:
     needs: Install_Alphapept
     runs-on: self-hosted
     steps:
     - uses: actions/checkout@v2
+    - name: Run CPU test script
+      run: |
+        conda activate alphapept_gpu
+        python test_gpu_.py numba-multithread
     - name: Run GPU test script
       run: |
-        python test_gpu.py
+        conda activate alphapept_gpu
+        python test_gpu_.py cuda
diff --git a/test_gpu.py b/test_gpu.py
deleted file mode 100644
index 718c4112..00000000
--- a/test_gpu.py
+++ /dev/null
@@ -1,235 +0,0 @@
-"""
-Dummy script to test gpu access
-
-"""
-
-import multiprocessing
-import threading
-import functools
-import math
-import numba as numba_
-numba = numba_
-import numpy as np
-from numba import cuda as cuda_
-cuda = cuda_
-
-import cupy
-
-
-@numba.njit
-def grid_1d(x): return -1
-@numba.njit
-def grid_2d(x): return -1, -1
-
-
-def set_cuda_grid(dimensions=0):
-    global cuda
-    if dimensions == 0:
-        cuda = cuda_
-        cuda.grid = cuda_.grid
-    if dimensions == 1:
-        cuda = numba_
-        cuda.grid = grid_1d
-    if dimensions == 2:
-        cuda = numba_
-        cuda.grid = grid_2d
-
-
-def parallel_compiled_func(
-    _func=None,
-    *,
-    cpu_threads=None,
-    dimensions=1,
-):
-    set_cuda_grid()
-    if dimensions not in (1, 2):
-        raise ValueError("Only 1D and 2D are supported")
-    if cpu_threads is not None:
-        use_gpu = False
-    else:
-        try:
-            cuda.get_current_device()
-        except cuda.CudaSupportError:
-            use_gpu = False
-            cpu_threads = 0
-        else:
-            use_gpu = True
-    if use_gpu:
-        set_cuda_grid()
-        def parallel_compiled_func_inner(func):
-            cuda_func = cuda.jit(func)
-            if dimensions == 1:
-                def wrapper(iterable_1d, *args):
-                    cuda_func.forall(iterable_1d.shape[0], 1)(
-                        -1,
-                        iterable_1d,
-                        *args
-                    )
-            elif dimensions == 2:
-                def wrapper(iterable_2d, *args):
-                    threadsperblock = (
-                        min(iterable_2d.shape[0], 16),
-                        min(iterable_2d.shape[0], 16)
-                    )
-                    blockspergrid_x = math.ceil(
-                        iterable_2d.shape[0] / threadsperblock[0]
-                    )
-                    blockspergrid_y = math.ceil(
-                        iterable_2d.shape[1] / threadsperblock[1]
-                    )
-                    blockspergrid = (blockspergrid_x, blockspergrid_y)
-                    cuda_func[blockspergrid, threadsperblock](
-                        -1,
-                        -1,
-                        iterable_2d,
-                        *args
-                    )
-            return functools.wraps(func)(wrapper)
-    else:
-        set_cuda_grid(dimensions)
-        if cpu_threads <= 0:
-            cpu_threads = multiprocessing.cpu_count()
-        def parallel_compiled_func_inner(func):
-            numba_func = numba.njit(nogil=True)(func)
-            if dimensions == 1:
-                def numba_func_parallel(
-                    thread,
-                    iterable_1d,
-                    *args
-                ):
-                    for i in range(
-                        thread,
-                        len(iterable_1d),
-                        cpu_threads
-                    ):
-                        numba_func(i, iterable_1d, *args)
-            elif dimensions == 2:
-                def numba_func_parallel(
-                    thread,
-                    iterable_2d,
-                    *args
-                ):
-                    for i in range(
-                        thread,
-                        iterable_2d.shape[0],
-                        cpu_threads
-                    ):
-                        for j in range(iterable_2d.shape[1]):
-                            numba_func(i, j, iterable_2d, *args)
-            numba_func_parallel = numba.njit(nogil=True)(numba_func_parallel)
-            def wrapper(iterable, *args):
-                threads = []
-                for thread_id in range(cpu_threads):
-                    t = threading.Thread(
-                        target=numba_func_parallel,
-                        args=(thread_id, iterable, *args)
-                    )
-                    t.start()
-                    threads.append(t)
-                for t in threads:
-                    t.join()
-                    del t
-            return functools.wraps(func)(wrapper)
-    if _func is None:
-        return parallel_compiled_func_inner
-    else:
-        return parallel_compiled_func_inner(_func)
-
-
-def test_func_1d(query_idx, in_array_1d, out_array_1d, complexity):
-    "query_idx should be an int satisfying 0<=query_idx<=len(in_array_1d)."
-    "In case query_idx == -1, a gpu pointer is assumed."
-    "Each different query_idx should write to a unique out_array_1d section"
-    if query_idx == -1:
-        query_idx = cuda.grid(1)
-    # simulate compute intensive function
-    x = 0
-    for i in range(complexity):
-        x += 1 + in_array_1d[query_idx]
-    # quick test to test if function generates correct output
-    out_array_1d[query_idx] = x
-
-
-@parallel_compiled_func(dimensions=2)
-def test_func_2d(
-    query_idx,
-    query_idy,
-    in_array_2d,
-    out_array_2d,
-    complexity
-):
-    if query_idx == -1:
-        query_idx, query_idy = cuda.grid(2)
-    # simulate compute intensive function
-    x = 0
-    for i in range(complexity):
-        x += 1 + in_array_2d[query_idx, query_idy]
-    # quick test to test if function generates correct output
-    out_array_2d[query_idx, query_idy] = x
-
-if __name__ == '__main__':
-    import time
-    size = 10**6
-    complexity = 10**4
-
-    # def test_func_1d_not_compiled(queries, *args):
-    #     for i in range(len(queries)):
-    #         test_func_1d(i, queries, *args)
-
-    # print("\nNot compiled:")
-    # %time test_func_not_compiled(in_array_1d, out_array_1d, complexity)
-
-    print("\n1 CPU thread:")
-    test_func_1d_cpu_1 = parallel_compiled_func(cpu_threads=1)(test_func_1d)
-    in_array_1d = np.around(100 * np.random.random(size) + 1)
-    out_array_1d = np.empty_like(in_array_1d)
-    s = time.time()
-    test_func_1d_cpu_1(in_array_1d, out_array_1d, complexity)
-    print(time.time() - s)
-    assert np.all(
-        (complexity * (1 + in_array_1d)) == out_array_1d
-    ), "1D function CPU not working"
-    assert (
-        test_func_1d_cpu_1.__doc__ == test_func_1d.__doc__
-    ), "Docstring not passed properly"
-
-    print(f"\n{multiprocessing.cpu_count()} (max) CPU threads:")
-    test_func_1d_cpu_max = parallel_compiled_func(cpu_threads=-1)(test_func_1d)
-    in_array_1d = np.around(100 * np.random.random(size) + 1)
-    out_array_1d = np.empty_like(in_array_1d)
-    s = time.time()
-    test_func_1d_cpu_max(in_array_1d, out_array_1d, complexity)
-    print(time.time() - s)
-    assert np.all(
-        (complexity * (1 + in_array_1d)) == out_array_1d
-    ), "1D function CPU not working"
-    assert (
-        test_func_1d_cpu_max.__doc__ == test_func_1d.__doc__
-    ), "Docstring not passed properly"
-
-    print(f"\nGPU:")
-    test_func_1d_gpu = parallel_compiled_func(test_func_1d)
-    in_array_1d_gpu = cupy.around(100 * np.random.random(size) + 1)
-    out_array_1d_gpu = cupy.empty_like(in_array_1d_gpu)
-    s = time.time()
-    test_func_1d_gpu(in_array_1d_gpu, out_array_1d_gpu, complexity)
-    print(time.time() - s)
-    assert np.all(
-        (complexity * (1 + in_array_1d_gpu)) == out_array_1d_gpu
-    ), "1D function GPU not working"
-    assert (
-        test_func_1d_gpu.__doc__ == test_func_1d.__doc__
-    ), "Docstring not passed properly"
-
-    print(f"\n2D GPU:")
-    in_array_2d_gpu = cupy.around(100 * np.random.random((size//10, 10)) + 1)
-    out_array_2d_gpu = cupy.empty_like(in_array_2d_gpu)
-    s = time.time()
-    test_func_2d(in_array_2d_gpu, out_array_2d_gpu, complexity)
-    print(time.time() - s)
-    assert np.all(
-        (complexity * (1 + in_array_2d_gpu)) == out_array_2d_gpu
-    ), "2D function not working"
-    assert (
-        test_func_2d.__doc__ == test_func_2d.__doc__
-    ), "Docstring not passed properly"
diff --git a/test_gpu_.py b/test_gpu_.py
new file mode 100644
index 00000000..32ab3c84
--- /dev/null
+++ b/test_gpu_.py
@@ -0,0 +1,66 @@
+import alphapept.performance
+import os
+from time import time
+import wget
+from alphapept.settings import load_settings
+from alphapept.paths import DEFAULT_SETTINGS_PATH
+import alphapept.interface
+import sys
+import shutil
+import logging
+import importlib
+import alphapept.feature_finding
+
+
+FILE_DICT = {}
+FILE_DICT['thermo_IRT.raw'] = 'https://datashare.biochem.mpg.de/s/GpXsATZtMwgQoQt/download'
+FILE_DICT['IRT_fasta.fasta'] = 'https://datashare.biochem.mpg.de/s/p8Qu3KolzbSiCHH/download'
+
+tmp_folder = 'E:/test_temp/'
+
+
+def delete_folder(dir_name):
+    if os.path.exists(dir_name):
+        shutil.rmtree(dir_name)
+
+
+def create_folder(dir_name):
+    if not os.path.exists(dir_name):
+        logging.info(f'Creating dir {dir_name}.')
+        os.makedirs(dir_name)
+
+
+def main():
+    mode = sys.argv[1]
+    print(f"Testing with mode {mode}")
+    global alphapept
+    alphapept.performance.set_compilation_mode(mode)
+    alphapept.performance.set_worker_count(0)
+    importlib.reload(alphapept.feature_finding)
+
+    delete_folder(tmp_folder)
+    create_folder(tmp_folder)
+
+    for file in FILE_DICT:
+        target = os.path.join(tmp_folder, file)
+        if not os.path.isfile(target):
+            wget.download(FILE_DICT[file], target)
+
+    settings = load_settings(DEFAULT_SETTINGS_PATH)
+    settings['experiment']['file_paths'] =  [os.path.join(tmp_folder, 'thermo_IRT.raw')]
+    settings['experiment']['fasta_paths'] = [os.path.join(tmp_folder, 'IRT_fasta.fasta')]
+
+    import alphapept.interface
+
+    settings_ = alphapept.interface.import_raw_data(settings)
+    start = time()
+    settings_ = alphapept.interface.feature_finding(settings)
+    end = time()
+
+    te = end-start
+
+    print(f'Time elapsed {te}')
+
+
+if __name__ == "__main__":
+    main()