diff --git a/MAINTAINERS b/MAINTAINERS
index eafe9f8c46..5e9dc1a1a7 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1588,6 +1588,8 @@ M: Declan Doherty <declan.doherty@intel.com>
 T: git://dpdk.org/next/dpdk-next-crypto
 F: app/test-crypto-perf/
 F: doc/guides/tools/cryptoperf.rst
+F: usertools/dpdk_graph_crypto_perf.py
+F: usertools/graph_crypto_perf_config.json
 
 Eventdev test application
 M: Jerin Jacob <jerinj@marvell.com>
diff --git a/doc/guides/tools/cryptoperf.rst b/doc/guides/tools/cryptoperf.rst
index 79359fe894..63d97319a8 100644
--- a/doc/guides/tools/cryptoperf.rst
+++ b/doc/guides/tools/cryptoperf.rst
@@ -453,3 +453,96 @@ Test vector file for cipher algorithm aes cbc 256 with authorization sha::
    digest =
    0x1C, 0xB2, 0x3D, 0xD1, 0xF9, 0xC7, 0x6C, 0x49, 0x2E, 0xDA, 0x94, 0x8B, 0xF1, 0xCF, 0x96, 0x43,
    0x67, 0x50, 0x39, 0x76, 0xB5, 0xA1, 0xCE, 0xA1, 0xD7, 0x77, 0x10, 0x07, 0x43, 0x37, 0x05, 0xB4
+
+
+Graph Crypto Perf Results
+-------------------------
+
+The ``dpdk_graph_crypto_perf.py`` usertool is a simple script to automate
+running crypto performance tests, and graphing the results.
+The output graphs include various grouped barcharts for throughput
+tests, and histogram and boxplot graphs for latency tests.
+These are output to PDF files, with one PDF per test suite.
+
+
+Test Configuration
+~~~~~~~~~~~~~~~~~~
+
+The test cases run by the script are outlined in the ``graph_crypto_perf_config.json`` file.
+An example of this configuration is shown below for one test suite,
+showing the default config for the test suite, and one test case.
+The test case has additional app config that will be combined with
+the default config when running the test case.
+
+.. code-block:: c
+
+   "crypto_aesni_mb_throughput": {
+       "default": {
+           "eal": {
+               "l": "1,2",
+               "log-level": "1",
+               "vdev": "crypto_aesni_mb"
+           },
+           "app": {
+               "csv-friendly": true,
+               "silent": true,
+               "buffer-sz": "64,128,256,512,768,1024,1408,2048",
+               "burst-sz": "1,4,8,16,32",
+               "ptest": "throughput",
+               "devtype": "crypto_aesni_mb"
+           }
+        },
+       "AES-CBC-128 SHA1-HMAC auth-then-cipher decrypt": {
+               "cipher-algo": "aes-cbc",
+               "cipher-key-sz": "16",
+               "auth-algo": "sha1-hmac",
+               "optype": "auth-then-cipher",
+               "cipher-op": "decrypt"
+        }
+   }
+
+Currently, crypto_qat, crypto_aesni_mb, and crypto_aesni_gcm devices for
+both throughput and latency ptests are supported.
+
+
+Usage
+~~~~~
+
+.. code-block:: console
+
+   ./dpdk_graph_crypto_perf
+
+The following are the application command-line options:
+
+* ``-f file_path``
+
+  Provide path to ``dpdk-test-crypto-perf`` application.
+  The script uses the installed app by default.
+
+  .. code-block:: console
+
+     ./dpdk_graph_crypto_perf -f <build_dir>/app/dpdk-test-crypto-perf
+
+
+* ``-t test_suite_list``
+
+  Specify test suites to run. All test suites are run by default.
+
+  To run all test suites
+
+  .. code-block:: console
+
+     ./dpdk_graph_crypto_perf -t all
+
+  To run crypto_qat latency test suite only
+
+  .. code-block:: console
+
+     ./dpdk_graph_crypto_perf -t crypto_qat_latency
+
+  To run both crypto_aesni_mb throughput and crypto_aesni_gcm latency test suites
+
+  .. code-block:: console
+
+     ./dpdk_graph_crypto_perf -t crypto_aesni_mb_throughput \
+         crypto_aesni_gcm_latency
diff --git a/usertools/dpdk_graph_crypto_perf.py b/usertools/dpdk_graph_crypto_perf.py
new file mode 100755
index 0000000000..a1361fb625
--- /dev/null
+++ b/usertools/dpdk_graph_crypto_perf.py
@@ -0,0 +1,249 @@
+#! /usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2020 Intel Corporation
+
+"""
+Script to automate running crypto performance tests for a range of test
+cases and devices as configured in the JSON file.
+The results are processed and output into various graphs in PDF files.
+Currently, throughput and latency tests are supported.
+"""
+
+import glob
+import json
+import os
+import shutil
+import subprocess
+from argparse import ArgumentParser
+from datetime import datetime
+import img2pdf
+import pandas as pd
+import plotly.express as px
+
+SCRIPT_PATH = os.path.dirname(__file__) + "/"
+GRAPHS_PATH = SCRIPT_PATH + "graph_crypto_perf_graphs/"
+PDFS_PATH = SCRIPT_PATH + "graph_crypto_perf_pdfs/"
+
+
+class Grapher:
+    """Grapher object containing all graphing functions. """
+    def __init__(self, dev):
+        self.graph_num = 0
+        self.dev = dev
+        self.test = ""
+        self.ptest = ""
+        self.data = pd.DataFrame()
+        if not os.path.exists(GRAPHS_PATH):
+            os.makedirs(GRAPHS_PATH)
+
+    def save_graph(self, fig):
+        """
+        Update figure layout to increase readability, output to JPG file.
+        """
+        fig.update_layout(font_size=30, title_x=0.5, title_font={"size": 30},
+                          margin=dict(t=200, l=150, r=150, b=150))
+        fig.write_image(GRAPHS_PATH + "%s_%d.jpg" % (self.dev,
+                                                     self.graph_num))
+
+    def boxplot_graph(self, x_axis_label):
+        """Plot a boxplot graph for the given parameters."""
+        fig = px.box(self.data, x=x_axis_label,
+                     title="Device: " + self.dev + "<br>" + self.test +
+                     "<br>(Outliers Included)", height=1200, width=2400)
+        self.save_graph(fig)
+        self.graph_num += 1
+
+    def grouped_graph(self, y_axis_label, x_axis_label, color_label):
+        """Plot a grouped barchart using the given parameters."""
+        if (self.data[y_axis_label] == 0).all():
+            return
+        fig = px.bar(self.data, x=x_axis_label, color=color_label,
+                     y=y_axis_label,
+                     title="Device: " + self.dev + "<br>" + self.test + "<br>"
+                     + y_axis_label + " for each " + x_axis_label +
+                     "/" + color_label,
+                     barmode="group",
+                     height=1200,
+                     width=2400)
+        fig.update_xaxes(type='category')
+        self.save_graph(fig)
+        self.graph_num += 1
+
+    def histogram_graph(self, x_axis_label):
+        """Plot a histogram graph using the given parameters."""
+        quart1 = self.data[x_axis_label].quantile(0.25)
+        quart3 = self.data[x_axis_label].quantile(0.75)
+        inter_quart_range = quart3 - quart1
+        dev_data_out = self.data[~((self.data[x_axis_label] <
+                                    (quart1 - 1.5 * inter_quart_range)) |
+                                   (self.data[x_axis_label] >
+                                    (quart3 + 1.5 * inter_quart_range)))]
+        fig = px.histogram(dev_data_out, x=x_axis_label,
+                           title="Device: " + self.dev + "<br>" + self.test +
+                           "<br>(Outliers removed using Interquartile Range)",
+                           height=1200,
+                           width=2400)
+        max_val = dev_data_out[x_axis_label].max()
+        min_val = dev_data_out[x_axis_label].min()
+        fig.update_traces(xbins=dict(
+            start=min_val,
+            end=max_val,
+            size=(max_val - min_val) / 200
+        ))
+        self.save_graph(fig)
+        self.graph_num += 1
+
+
+def cleanup_throughput_datatypes(data):
+    """Cleanup data types of throughput test results dataframe. """
+    data['burst_size'] = data['burst_size'].astype('int')
+    data['buffer_size(b)'] = data['buffer_size(b)'].astype('int')
+    data['burst_size'] = data['burst_size'].astype('category')
+    data['buffer_size(b)'] = data['buffer_size(b)'].astype('category')
+    data['failed_enq'] = data['failed_enq'].astype('int')
+    data['throughput(gbps)'] = data['throughput(gbps)'].astype('float')
+    data['ops(millions)'] = data['ops(millions)'].astype('float')
+    data['cycles_per_buf'] = data['cycles_per_buf'].astype('float')
+    return data
+
+
+def process_test_results(grapher, data):
+    """
+    Process results from the test case,
+    calling graph functions to output graph images.
+    """
+    print("\tProcessing Test Case Results: " + grapher.test)
+    if grapher.ptest == "throughput":
+        grapher.data = cleanup_throughput_datatypes(data)
+        for y_label in ["throughput(gbps)", "ops(millions)",
+                        "cycles_per_buf", "failed_enq"]:
+            grapher.grouped_graph(y_label, "buffer_size(b)",
+                                  "burst_size")
+    elif grapher.ptest == "latency":
+        data['time(us)'] = data['time(us)'].astype('float')
+        grapher.data = data
+        grapher.histogram_graph("time(us)")
+        grapher.boxplot_graph("time(us)")
+    else:
+        print("Invalid ptest")
+        return
+
+
+def create_results_pdf(dev):
+    """Output results graphs to one PDF."""
+    if not os.path.exists(PDFS_PATH):
+        os.makedirs(PDFS_PATH)
+    dev_graphs = sorted(glob.glob(GRAPHS_PATH + "%s_*.jpg" % dev), key=(
+        lambda x: int((x.rsplit('_', 1)[1]).split('.')[0])))
+    if dev_graphs:
+        with open(PDFS_PATH + "/%s_results.pdf" % dev, "wb") as pdf_file:
+            pdf_file.write(img2pdf.convert(dev_graphs))
+
+
+def run_test(test_cmd, test, grapher, timestamp, params):
+    """Run performance test app for the given test case parameters."""
+    print("\n\tRunning Test Case: " + test)
+    try:
+        process_out = subprocess.check_output([test_cmd] + params,
+                                              universal_newlines=True,
+                                              stderr=subprocess.STDOUT)
+        rows = []
+        for line in process_out.split('\n'):
+            if not line:
+                continue
+            if line.startswith('#'):
+                columns = line[1:].split(',')
+            elif line[0].isdigit():
+                rows.append(line.split(','))
+            else:
+                continue
+        data = pd.DataFrame(rows, columns=columns)
+        data['date'] = timestamp
+        grapher.test = test
+        process_test_results(grapher, data)
+    except subprocess.CalledProcessError as err:
+        print("\tCannot run performance test application for: " + str(err))
+        return
+
+
+def run_test_suite(test_cmd, dut, test_cases, timestamp):
+    """Parse test cases for the test suite and run each test."""
+    print("\nRunning Test Suite: " + dut)
+    default_params = []
+    grapher = Grapher(dut)
+    for (key, val) in test_cases['default']['eal'].items():
+        if len(key) == 1:
+            default_params.append("-" + key + " " + val)
+        else:
+            default_params.append("--" + key + "=" + val)
+
+    default_params.append("--")
+    for (key, val) in test_cases['default']['app'].items():
+        if isinstance(val, bool):
+            default_params.append("--" + key if val is True else "")
+        else:
+            default_params.append("--" + key + "=" + val)
+
+    if 'ptest' not in test_cases['default']['app']:
+        print("Test Suite must contain default ptest value, skipping")
+        return
+    grapher.ptest = test_cases['default']['app']['ptest']
+
+    for (test, params) in {k: v for (k, v) in test_cases.items() if
+                           k != "default"}.items():
+        extra_params = []
+        for (key, val) in params.items():
+            extra_params.append("--" + key + "=" + val)
+        run_test(test_cmd, test, grapher, timestamp,
+                 default_params + extra_params)
+
+    create_results_pdf(dut)
+
+
+def parse_args():
+    """Parse command-line arguments passed to script."""
+    parser = ArgumentParser()
+    parser.add_argument('-f', '--file-path',
+                        default=shutil.which('dpdk-test-crypto-perf'),
+                        help="Path for test perf app")
+    parser.add_argument('-t', '--test-suites', nargs='+', default=["all"],
+                        help="List of device test suites to run")
+    args = parser.parse_args()
+    return args.file_path, args.test_suites
+
+
+def main():
+    """
+    Load JSON config and call relevant functions to run chosen test suites.
+    """
+    test_cmd, test_suites = parse_args()
+    if not os.path.isfile(test_cmd):
+        print("Invalid filepath!")
+        return
+    try:
+        with open(SCRIPT_PATH + 'graph_crypto_perf_config.json') as conf:
+            test_suite_options = json.load(conf)
+    except json.decoder.JSONDecodeError as err:
+        print("Error loading JSON config: " + err.msg)
+        return
+    timestamp = pd.Timestamp(datetime.now())
+
+    if test_suites != ["all"]:
+        dev_list = []
+        for (dut, test_cases) in {k: v for (k, v) in test_suite_options.items()
+                                  if k in test_suites}.items():
+            dev_list.append(dut)
+            run_test_suite(test_cmd, dut, test_cases, timestamp)
+        if not dev_list:
+            print("No valid device test suites chosen!")
+            return
+    else:
+        for (dut, test_cases) in test_suite_options.items():
+            run_test_suite(test_cmd, dut, test_cases, timestamp)
+
+    if os.path.exists(GRAPHS_PATH):
+        shutil.rmtree(GRAPHS_PATH)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/usertools/graph_crypto_perf_config.json b/usertools/graph_crypto_perf_config.json
new file mode 100644
index 0000000000..004ec3e84e
--- /dev/null
+++ b/usertools/graph_crypto_perf_config.json
@@ -0,0 +1,309 @@
+{
+	"crypto_aesni_mb_throughput": {
+		"default": {
+			"eal": {
+				"l": "1,2",
+				"log-level": "1",
+				"vdev": "crypto_aesni_mb"
+			},
+			"app": {
+				"csv-friendly": true,
+				"silent": true,
+				"buffer-sz": "64,128,256,512,768,1024,1408,2048",
+				"burst-sz": "1,4,8,16,32",
+				"ptest": "throughput",
+				"devtype": "crypto_aesni_mb"
+			}
+		},
+		"AES-CBC-128 SHA1-HMAC auth-then-cipher decrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "16",
+			"auth-algo": "sha1-hmac",
+			"optype": "auth-then-cipher",
+			"cipher-op": "decrypt"
+		},
+		"AES-CBC-128 SHA1-HMAC cipher-then-auth encrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "16",
+			"auth-algo": "sha1-hmac",
+			"auth-op": "generate",
+			"auth-key-sz": "64",
+			"digest-sz": "20",
+			"optype": "cipher-then-auth",
+			"cipher-op": "encrypt"
+		},
+		"AES-CBC-256 SHA2-256-HMAC auth-then-cipher decrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "32",
+			"auth-algo": "sha2-256-hmac",
+			"optype": "auth-then-cipher",
+			"cipher-op": "decrypt"
+		},
+		"AES-CBC-256 SHA2-256-HMAC cipher-then-auth encrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "32",
+			"auth-algo": "sha2-256-hmac",
+			"optype": "cipher-then-auth"
+		},
+		"AES-GCM-128 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-iv-sz": "12",
+			"aead-op": "encrypt",
+			"aead-aad-sz": "16",
+			"digest-sz": "16",
+			"optype": "aead",
+			"total-ops": "10000000"
+		},
+		"AES-GCM-128 aead-op decrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-op": "decrypt"
+		},
+		"AES-GCM-256 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "encrypt"
+		},
+		"AES-GCM-256 aead-op decrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "decrypt"
+		},
+		"AES-GMAC 128 auth-only generate": {
+			"auth-algo": "aes-gmac",
+			"auth-key-sz": "16",
+			"auth-iv-sz": "12",
+			"auth-op": "generate",
+			"digest-sz": "16",
+			"optype": "auth-only",
+			"total-ops": "10000000"
+		}
+	},
+	"crypto_aesni_mb_latency": {
+		"default": {
+			"eal": {
+				"l": "1,2",
+				"log-level": "1",
+				"vdev": "crypto_aesni_mb"
+			},
+			"app": {
+				"csv-friendly": true,
+				"silent": true,
+				"buffer-sz": "1024",
+				"burst-sz": "16",
+				"ptest": "latency",
+				"devtype": "crypto_aesni_mb"
+			}
+		},
+		"AES-CBC-128 SHA1-HMAC auth-then-cipher decrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "16",
+			"auth-algo": "sha1-hmac",
+			"optype": "auth-then-cipher",
+			"cipher-op": "decrypt"
+		},
+		"AES-GCM-256 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "encrypt"
+		}
+	},
+	"crypto_aesni_gcm_throughput": {
+		"default": {
+			"eal": {
+				"l": "1,2",
+				"log-level": "1",
+				"vdev": "crypto_aesni_gcm"
+			},
+			"app": {
+				"csv-friendly": true,
+				"silent": true,
+				"buffer-sz": "64,128,256,512,768,1024,1408,2048",
+				"burst-sz": "1,4,8,16,32",
+				"ptest": "throughput",
+				"devtype": "crypto_aesni_gcm"
+			}
+		},
+		"AES-GCM-128 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-iv-sz": "12",
+			"aead-op": "encrypt",
+			"aead-aad-sz": "16",
+			"digest-sz": "16",
+			"optype": "aead",
+			"total-ops": "10000000"
+		},
+		"AES-GCM-128 aead-op decrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-op": "decrypt",
+			"aead-aad-sz": "16",
+			"aead-iv-sz": "12",
+			"digest-sz": "16",
+			"optype": "aead",
+			"total-ops": "10000000"
+		},
+		"AES-GCM-256 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "encrypt",
+			"aead-aad-sz": "32",
+			"aead-iv-sz": "12",
+			"digest-sz": "16",
+			"optype": "aead",
+			"total-ops": "10000000"
+		},
+		"AES-GCM-256 aead-op decrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "decrypt",
+			"aead-aad-sz": "32",
+			"aead-iv-sz": "12",
+			"digest-sz": "16",
+			"optype": "aead",
+			"total-ops": "10000000"
+		},
+		"AES-GMAC 128 auth-only generate": {
+			"auth-algo": "aes-gmac",
+			"auth-key-sz": "16",
+			"auth-iv-sz": "12",
+			"auth-op": "generate",
+			"digest-sz": "16",
+			"optype": "auth-only",
+			"total-ops": "10000000"
+		}
+	},
+	"crypto_aesni_gcm_latency": {
+		"default": {
+			"eal": {
+				"l": "1,2",
+				"log-level": "1",
+				"vdev": "crypto_aesni_gcm"
+			},
+			"app": {
+				"csv-friendly": true,
+				"silent": true,
+				"buffer-sz": "1024",
+				"burst-sz": "16",
+				"ptest": "latency",
+				"devtype": "crypto_aesni_gcm"
+			}
+		},
+		"AES-GCM-128 aead-op decrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-op": "decrypt",
+			"aead-aad-sz": "16",
+			"aead-iv-sz": "12",
+			"digest-sz": "16",
+			"optype": "aead"
+		},
+		"AES-GCM-256 aead-op encrypt latency": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "encrypt",
+			"aead-aad-sz": "32",
+			"aead-iv-sz": "12",
+			"digest-sz": "16",
+			"optype": "aead"
+		}
+	},
+	"crypto_qat_throughput": {
+		"default": {
+			"eal": {
+				"l": "1,2",
+				"log-level": "1"
+			},
+			"app": {
+				"csv-friendly": true,
+				"silent": true,
+				"buffer-sz": "64,128,256,512,768,1024,1408,2048",
+				"burst-sz": "1,4,8,16,32",
+				"devtype": "crypto_qat",
+				"ptest": "throughput"
+			}
+		},
+		"AES-CBC-128 SHA1-HMAC auth-then-cipher decrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "16",
+			"auth-algo": "sha1-hmac",
+			"optype": "auth-then-cipher",
+			"cipher-op": "decrypt"
+		},
+		"AES-CBC-128 SHA1-HMAC cipher-then-auth encrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "16",
+			"auth-algo": "sha1-hmac",
+			"optype": "cipher-then-auth",
+			"cipher-op": "encrypt"
+		},
+		"AES-CBC-256 SHA2-256-HMAC auth-then-cipher decrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "32",
+			"auth-algo": "sha2-256-hmac",
+			"optype": "auth-then-cipher",
+			"cipher-op": "decrypt"
+		},
+		"AES-CBC-256 SHA2-256-HMAC cipher-then-auth encrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "32",
+			"auth-algo": "sha2-256-hmac",
+			"optype": "cipher-then-auth",
+			"cipher-op": "encrypt"
+		},
+		"AES-GCM-128 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-iv-sz": "12",
+			"aead-op": "encrypt",
+			"aead-aad-sz": "16",
+			"digest-sz": "16",
+			"optype": "aead"
+		},
+		"AES-GCM-128 aead-op decrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-op": "decrypt"
+		},
+		"AES-GCM-256 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "encrypt"
+		},
+		"AES-GCM-256 aead-op decrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "32",
+			"aead-op": "decrypt"
+		}
+	},
+	"crypto_qat_latency": {
+		"default": {
+			"eal": {
+				"l": "1,2",
+				"log-level": "1"
+			},
+			"app": {
+				"csv-friendly": true,
+				"silent": true,
+				"ptest": "latency",
+				"buffer-sz": "1024",
+				"burst-sz": "16",
+				"devtype": "crypto_qat"
+			}
+		},
+		"AES-CBC-256 SHA2-256-HMAC cipher-then-auth encrypt": {
+			"cipher-algo": "aes-cbc",
+			"cipher-key-sz": "32",
+			"auth-algo": "sha2-256-hmac",
+			"optype": "cipher-then-auth",
+			"cipher-op": "encrypt"
+		},
+		"AES-GCM-128 aead-op encrypt": {
+			"aead-algo": "aes-gcm",
+			"aead-key-sz": "16",
+			"aead-op": "encrypt"
+		}
+	}
+}
\ No newline at end of file
