[ONOS-6841] Sustained primitive throughput tests

Change-Id: Ibdd05bd868a5d481b8967e57797d6106026ba1ac
diff --git a/apps/test/pom.xml b/apps/test/pom.xml
index 181e376..3f91291 100644
--- a/apps/test/pom.xml
+++ b/apps/test/pom.xml
@@ -36,6 +36,7 @@
         <module>intent-perf</module>
         <module>messaging-perf</module>
         <module>flow-perf</module>
+        <module>primitive-perf</module>
         <module>transaction-perf</module>
         <module>demo</module>
         <module>distributed-primitives</module>
diff --git a/apps/test/primitive-perf/BUCK b/apps/test/primitive-perf/BUCK
new file mode 100644
index 0000000..00dade9
--- /dev/null
+++ b/apps/test/primitive-perf/BUCK
@@ -0,0 +1,20 @@
+COMPILE_DEPS = [
+    '//lib:CORE_DEPS',
+    '//lib:org.apache.karaf.shell.console',
+    '//cli:onos-cli',
+    '//utils/rest:onlab-rest',
+    '//lib:javax.ws.rs-api',
+    '//core/store/serializers:onos-core-serializers',
+]
+
+osgi_jar_with_tests (
+    deps = COMPILE_DEPS,
+)
+
+onos_app (
+    app_name = 'org.onosproject.primitiveperf',
+    title = 'Primitive Performance Test App',
+    category = 'Test',
+    url = 'http://onosproject.org',
+    description = 'Primitive performance test application.',
+)
diff --git a/apps/test/primitive-perf/pom.xml b/apps/test/primitive-perf/pom.xml
new file mode 100644
index 0000000..e0abe0d
--- /dev/null
+++ b/apps/test/primitive-perf/pom.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Copyright 2015-present Open Networking Laboratory
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.onosproject</groupId>
+        <artifactId>onos-apps-test</artifactId>
+        <version>1.11.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>onos-app-primitive-perf</artifactId>
+    <packaging>bundle</packaging>
+
+    <description>Primitive performance test application</description>
+
+    <properties>
+        <onos.app.name>org.onosproject.primitiveperf</onos.app.name>
+        <onos.app.title>Primitive Performance Test App</onos.app.title>
+        <onos.app.category>Test</onos.app.category>
+        <onos.app.url>http://onosproject.org</onos.app.url>
+        <onos.app.readme>Primitive performance test application.</onos.app.readme>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.onosproject</groupId>
+            <artifactId>onos-cli</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.compendium</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.onosproject</groupId>
+            <artifactId>onos-core-serializers</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.karaf.shell</groupId>
+            <artifactId>org.apache.karaf.shell.console</artifactId>
+        </dependency>
+        <!-- Required for javadoc generation -->
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.core</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>
diff --git a/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfApp.java b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfApp.java
new file mode 100644
index 0000000..7af34af
--- /dev/null
+++ b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfApp.java
@@ -0,0 +1,334 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.onosproject.primitiveperf;
+
+import java.util.Dictionary;
+import java.util.List;
+import java.util.Map;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.TreeMap;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Consumer;
+
+import com.google.common.collect.Lists;
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Modified;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.ReferenceCardinality;
+import org.apache.felix.scr.annotations.Service;
+import org.onosproject.cfg.ComponentConfigService;
+import org.onosproject.cluster.ClusterService;
+import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.NodeId;
+import org.onosproject.store.cluster.messaging.ClusterCommunicationService;
+import org.onosproject.store.cluster.messaging.MessageSubject;
+import org.onosproject.store.serializers.KryoNamespaces;
+import org.onosproject.store.service.ConsistentMap;
+import org.onosproject.store.service.Serializer;
+import org.onosproject.store.service.StorageService;
+import org.osgi.service.component.ComponentContext;
+import org.slf4j.Logger;
+
+import static com.google.common.base.Strings.isNullOrEmpty;
+import static java.lang.System.currentTimeMillis;
+import static org.apache.felix.scr.annotations.ReferenceCardinality.MANDATORY_UNARY;
+import static org.onlab.util.Tools.get;
+import static org.onlab.util.Tools.groupedThreads;
+import static org.slf4j.LoggerFactory.getLogger;
+
+/**
+ * Application to test sustained primitive throughput.
+ */
+@Component(immediate = true)
+@Service(value = PrimitivePerfApp.class)
+public class PrimitivePerfApp {
+
+    private final Logger log = getLogger(getClass());
+
+    private static final int DEFAULT_NUM_CLIENTS = 64;
+    private static final int DEFAULT_WRITE_PERCENTAGE = 100;
+
+    private static final int REPORT_PERIOD = 1_000; //ms
+
+    private static final String START = "start";
+    private static final String STOP = "stop";
+    private static final MessageSubject CONTROL = new MessageSubject("primitive-perf-ctl");
+
+    @Property(name = "numClients", intValue = DEFAULT_NUM_CLIENTS,
+            label = "Number of clients to use to submit writes")
+    private int numClients = DEFAULT_NUM_CLIENTS;
+
+    @Property(name = "writePercentage", intValue = DEFAULT_WRITE_PERCENTAGE,
+            label = "Percentage of operations to perform as writes")
+    private int writePercentage = DEFAULT_WRITE_PERCENTAGE;
+
+    @Reference(cardinality = MANDATORY_UNARY)
+    protected ClusterService clusterService;
+
+    @Reference(cardinality = MANDATORY_UNARY)
+    protected StorageService storageService;
+
+    @Reference(cardinality = MANDATORY_UNARY)
+    protected ComponentConfigService configService;
+
+    @Reference(cardinality = MANDATORY_UNARY)
+    protected PrimitivePerfCollector sampleCollector;
+
+    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
+    protected ClusterCommunicationService communicationService;
+
+    private ExecutorService messageHandlingExecutor;
+
+    private ExecutorService workers;
+    private boolean stopped = true;
+
+    private Timer reportTimer;
+
+    private NodeId nodeId;
+    private TimerTask reporterTask;
+
+    private long startTime;
+    private long currentStartTime;
+    private AtomicLong overallCounter;
+    private AtomicLong currentCounter;
+
+    @Activate
+    public void activate(ComponentContext context) {
+        configService.registerProperties(getClass());
+
+        nodeId = clusterService.getLocalNode().id();
+
+        reportTimer = new Timer("onos-primitive-perf-reporter");
+
+        messageHandlingExecutor = Executors.newSingleThreadExecutor(
+                groupedThreads("onos/perf", "command-handler"));
+
+        communicationService.addSubscriber(CONTROL, String::new, new InternalControl(),
+                                           messageHandlingExecutor);
+
+        // TODO: investigate why this seems to be necessary for configs to get picked up on initial activation
+        modify(context);
+    }
+
+    @Deactivate
+    public void deactivate() {
+        stopTestRun();
+
+        configService.unregisterProperties(getClass(), false);
+        messageHandlingExecutor.shutdown();
+        communicationService.removeSubscriber(CONTROL);
+
+        if (reportTimer != null) {
+            reportTimer.cancel();
+            reportTimer = null;
+        }
+    }
+
+    @Modified
+    public void modify(ComponentContext context) {
+        if (context == null) {
+            logConfig("Reconfigured");
+            return;
+        }
+
+        Dictionary<?, ?> properties = context.getProperties();
+        int newNumClients;
+        try {
+            String s = get(properties, "numClients");
+            newNumClients = isNullOrEmpty(s) ? numClients : Integer.parseInt(s.trim());
+        } catch (NumberFormatException | ClassCastException e) {
+            log.warn("Malformed configuration detected; using defaults", e);
+            newNumClients = DEFAULT_NUM_CLIENTS;
+        }
+
+        int newWritePercentage;
+        try {
+            String s = get(properties, "writePercentage");
+            newWritePercentage = isNullOrEmpty(s) ? writePercentage : Integer.parseInt(s.trim());
+        } catch (NumberFormatException | ClassCastException e) {
+            log.warn("Malformed configuration detected; using defaults", e);
+            newWritePercentage = DEFAULT_WRITE_PERCENTAGE;
+        }
+
+        if (newNumClients != numClients || newWritePercentage != writePercentage) {
+            numClients = newNumClients;
+            writePercentage = newWritePercentage;
+            logConfig("Reconfigured");
+            if (!stopped) {
+                stop();
+                start();
+            }
+        }
+    }
+
+    public void start() {
+        if (stopped) {
+            stopped = false;
+            communicationService.broadcast(START, CONTROL, str -> str.getBytes());
+            startTestRun();
+        }
+    }
+
+    public void stop() {
+        if (!stopped) {
+            communicationService.broadcast(STOP, CONTROL, str -> str.getBytes());
+            stopTestRun();
+        }
+    }
+
+    private void logConfig(String prefix) {
+        log.info("{} with numClients = {}; writePercentage = {}", prefix, numClients, writePercentage);
+    }
+
+    private void startTestRun() {
+        sampleCollector.clearSamples();
+
+        startTime = System.currentTimeMillis();
+        currentStartTime = startTime;
+        currentCounter = new AtomicLong();
+        overallCounter = new AtomicLong();
+
+        reporterTask = new ReporterTask();
+        reportTimer.scheduleAtFixedRate(reporterTask,
+                                        REPORT_PERIOD - currentTimeMillis() % REPORT_PERIOD,
+                                        REPORT_PERIOD);
+
+        stopped = false;
+
+        Map<String, ControllerNode> nodes = new TreeMap<>();
+        for (ControllerNode node : clusterService.getNodes()) {
+            nodes.put(node.id().id(), node);
+        }
+
+        // Compute the index of the local node in a sorted list of nodes.
+        List<String> sortedNodes = Lists.newArrayList(nodes.keySet());
+        int nodeCount = nodes.size();
+        int index = sortedNodes.indexOf(nodeId.id());
+
+        // Count the number of workers assigned to this node.
+        int workerCount = 0;
+        for (int i = 1; i <= numClients; i++) {
+            if (i % nodeCount == index) {
+                workerCount++;
+            }
+        }
+
+        // Create a worker pool and start the workers for this node.
+        workers = Executors.newFixedThreadPool(workerCount, groupedThreads("onos/primitive-perf", "worker-%d"));
+        for (int i = 0; i < workerCount; i++) {
+            workers.submit(new Runner(UUID.randomUUID().toString(), UUID.randomUUID().toString()));
+        }
+        log.info("Started test run");
+    }
+
+    private void stopTestRun() {
+        if (reporterTask != null) {
+            reporterTask.cancel();
+            reporterTask = null;
+        }
+
+        try {
+            workers.awaitTermination(10, TimeUnit.MILLISECONDS);
+        } catch (InterruptedException e) {
+            log.warn("Failed to stop worker", e);
+        }
+
+        sampleCollector.recordSample(0, 0);
+        sampleCollector.recordSample(0, 0);
+        stopped = true;
+
+        log.info("Stopped test run");
+    }
+
+    // Submits primitive operations.
+    final class Runner implements Runnable {
+        private final String key;
+        private final String value;
+        private ConsistentMap<String, String> map;
+
+        private Runner(String key, String value) {
+            this.key = key;
+            this.value = value;
+        }
+
+        @Override
+        public void run() {
+            setup();
+            while (!stopped) {
+                try {
+                    submit();
+                } catch (Exception e) {
+                    log.warn("Exception during cycle", e);
+                }
+            }
+            teardown();
+        }
+
+        private void setup() {
+            map = storageService.<String, String>consistentMapBuilder()
+                    .withName("perf-test")
+                    .withSerializer(Serializer.using(KryoNamespaces.BASIC))
+                    .build();
+        }
+
+        private void submit() {
+            if (currentCounter.incrementAndGet() % 100 < writePercentage) {
+                map.put(key, value);
+            } else {
+                map.get(key);
+            }
+        }
+
+        private void teardown() {
+            map.destroy();
+        }
+    }
+
+    private class InternalControl implements Consumer<String> {
+        @Override
+        public void accept(String cmd) {
+            log.info("Received command {}", cmd);
+            if (cmd.equals(START)) {
+                startTestRun();
+            } else {
+                stopTestRun();
+            }
+        }
+    }
+
+    private class ReporterTask extends TimerTask {
+        @Override
+        public void run() {
+            long endTime = System.currentTimeMillis();
+            long overallTime = endTime - startTime;
+            long currentTime = endTime - currentStartTime;
+            long currentCount = currentCounter.getAndSet(0);
+            long overallCount = overallCounter.addAndGet(currentCount);
+            sampleCollector.recordSample(overallTime > 0 ? overallCount / (overallTime / 1000d) : 0,
+                    currentTime > 0 ? currentCount / (currentTime / 1000d) : 0);
+            currentStartTime = System.currentTimeMillis();
+        }
+    }
+
+}
diff --git a/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfCollector.java b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfCollector.java
new file mode 100644
index 0000000..4af23b8
--- /dev/null
+++ b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfCollector.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.onosproject.primitiveperf;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import com.google.common.collect.ImmutableList;
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.ReferenceCardinality;
+import org.apache.felix.scr.annotations.Service;
+import org.onosproject.cluster.ClusterService;
+import org.onosproject.cluster.ControllerNode;
+import org.onosproject.cluster.NodeId;
+import org.onosproject.store.cluster.messaging.ClusterCommunicationService;
+import org.onosproject.store.cluster.messaging.ClusterMessage;
+import org.onosproject.store.cluster.messaging.ClusterMessageHandler;
+import org.onosproject.store.cluster.messaging.MessageSubject;
+import org.slf4j.Logger;
+
+import static org.onlab.util.SharedExecutors.getPoolThreadExecutor;
+import static org.slf4j.LoggerFactory.getLogger;
+
+/**
+ * Collects and distributes performance samples.
+ */
+@Component(immediate = true)
+@Service(value = PrimitivePerfCollector.class)
+public class PrimitivePerfCollector {
+
+    private static final long SAMPLE_TIME_WINDOW_MS = 5_000;
+    private final Logger log = getLogger(getClass());
+
+    private static final int MAX_SAMPLES = 1_000;
+
+    private final List<Sample> samples = new LinkedList<>();
+
+    private static final MessageSubject SAMPLE = new MessageSubject("primitive-perf-sample");
+
+    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
+    protected ClusterCommunicationService communicationService;
+
+    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
+    protected ClusterService clusterService;
+
+    // Auxiliary structures used to accrue data for normalized time interval
+    // across all nodes.
+    private long newestTime;
+    private Sample overall;
+    private Sample current;
+
+    private ControllerNode[] nodes;
+    private Map<NodeId, Integer> nodeToIndex;
+
+    private NodeId nodeId;
+
+    @Activate
+    public void activate() {
+        nodeId = clusterService.getLocalNode().id();
+
+        communicationService.addSubscriber(SAMPLE, new InternalSampleCollector(),
+                                           getPoolThreadExecutor());
+
+        nodes = clusterService.getNodes().toArray(new ControllerNode[]{});
+        Arrays.sort(nodes, (a, b) -> a.id().toString().compareTo(b.id().toString()));
+
+        nodeToIndex = new HashMap<>();
+        for (int i = 0; i < nodes.length; i++) {
+            nodeToIndex.put(nodes[i].id(), i);
+        }
+
+        clearSamples();
+        log.info("Started");
+    }
+
+    @Deactivate
+    public void deactivate() {
+        communicationService.removeSubscriber(SAMPLE);
+        log.info("Stopped");
+    }
+
+    /**
+     * Clears all previously accumulated data.
+     */
+    public synchronized void clearSamples() {
+        newestTime = 0;
+        overall = new Sample(0, nodes.length);
+        current = new Sample(0, nodes.length);
+        samples.clear();
+    }
+
+
+    /**
+     * Records a sample point of data about primitive operation rate.
+     *
+     * @param overallRate overall rate
+     * @param currentRate current rate
+     */
+    public void recordSample(double overallRate, double currentRate) {
+        long now = System.currentTimeMillis();
+        addSample(now, nodeId, overallRate, currentRate);
+        broadcastSample(now, nodeId, overallRate, currentRate);
+    }
+
+    /**
+     * Returns set of node ids as headers.
+     *
+     * @return node id headers
+     */
+    public List<String> getSampleHeaders() {
+        List<String> headers = new ArrayList<>();
+        for (ControllerNode node : nodes) {
+            headers.add(node.id().toString());
+        }
+        return headers;
+    }
+
+    /**
+     * Returns set of all accumulated samples normalized to the local set of
+     * samples.
+     *
+     * @return accumulated samples
+     */
+    public synchronized List<Sample> getSamples() {
+        return ImmutableList.copyOf(samples);
+    }
+
+    /**
+     * Returns overall throughput performance for each of the cluster nodes.
+     *
+     * @return overall primitive throughput
+     */
+    public synchronized Sample getOverall() {
+        return overall;
+    }
+
+    // Records a new sample to our collection of samples
+    private synchronized void addSample(long time, NodeId nodeId,
+                                        double overallRate, double currentRate) {
+        Sample fullSample = createCurrentSampleIfNeeded(time);
+        setSampleData(current, nodeId, currentRate);
+        setSampleData(overall, nodeId, overallRate);
+        pruneSamplesIfNeeded();
+    }
+
+    private Sample createCurrentSampleIfNeeded(long time) {
+        Sample oldSample = time - newestTime > SAMPLE_TIME_WINDOW_MS || current.isComplete() ? current : null;
+        if (oldSample != null) {
+            newestTime = time;
+            current = new Sample(time, nodes.length);
+            if (oldSample.time > 0) {
+                samples.add(oldSample);
+            }
+        }
+        return oldSample;
+    }
+
+    private void setSampleData(Sample sample, NodeId nodeId, double data) {
+        Integer index = nodeToIndex.get(nodeId);
+        if (index != null) {
+            sample.data[index] = data;
+        }
+    }
+
+    private void pruneSamplesIfNeeded() {
+        if (samples.size() > MAX_SAMPLES) {
+            samples.remove(0);
+        }
+    }
+
+    // Performance data sample.
+    static class Sample {
+        final long time;
+        final double[] data;
+
+        public Sample(long time, int nodeCount) {
+            this.time = time;
+            this.data = new double[nodeCount];
+            Arrays.fill(data, -1);
+        }
+
+        public boolean isComplete() {
+            for (int i = 0; i < data.length; i++) {
+                if (data[i] < 0) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    private void broadcastSample(long time, NodeId nodeId, double overallRate, double currentRate) {
+        String data = String.format("%d|%f|%f", time, overallRate, currentRate);
+        communicationService.broadcast(data, SAMPLE, str -> str.getBytes());
+    }
+
+    private class InternalSampleCollector implements ClusterMessageHandler {
+        @Override
+        public void handle(ClusterMessage message) {
+            String[] fields = new String(message.payload()).split("\\|");
+            log.debug("Received sample from {}: {}", message.sender(), fields);
+            addSample(Long.parseLong(fields[0]), message.sender(),
+                      Double.parseDouble(fields[1]), Double.parseDouble(fields[2]));
+        }
+    }
+}
diff --git a/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfListCommand.java b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfListCommand.java
new file mode 100644
index 0000000..2e25586
--- /dev/null
+++ b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfListCommand.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.onosproject.primitiveperf;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+import org.apache.karaf.shell.commands.Command;
+import org.apache.karaf.shell.commands.Option;
+import org.onosproject.cli.AbstractShellCommand;
+import org.onosproject.primitiveperf.PrimitivePerfCollector.Sample;
+
+/**
+ * Displays accumulated performance metrics.
+ */
+@Command(scope = "onos", name = "primitive-perf",
+        description = "Displays accumulated performance metrics")
+public class PrimitivePerfListCommand extends AbstractShellCommand {
+
+    @Option(name = "-s", aliases = "--summary", description = "Output just summary",
+            required = false, multiValued = false)
+    private boolean summary = false;
+
+    @Override
+    protected void execute() {
+        if (summary) {
+            printSummary();
+        } else {
+            printSamples();
+        }
+    }
+
+    private void printSummary() {
+        PrimitivePerfCollector collector = get(PrimitivePerfCollector.class);
+        List<String> headers = collector.getSampleHeaders();
+        Sample overall = collector.getOverall();
+        double total = 0;
+        print("%12s: %14s", "Node ID", "Overall Rate");
+        for (int i = 0; i < overall.data.length; i++) {
+            if (overall.data[i] >= 0) {
+                print("%12s: %14.2f", headers.get(i), overall.data[i]);
+                total += overall.data[i];
+            } else {
+                print("%12s: %14s", headers.get(i), " ");
+            }
+        }
+        print("%12s: %14.2f", "total", total);
+    }
+
+    private void printSamples() {
+        PrimitivePerfCollector collector = get(PrimitivePerfCollector.class);
+        List<String> headers = collector.getSampleHeaders();
+        List<Sample> samples = collector.getSamples();
+
+        System.out.print(String.format("%10s  ", "Time"));
+        for (String header : headers) {
+            System.out.print(String.format("%12s  ", header));
+        }
+        System.out.println(String.format("%12s", "Total"));
+
+        SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
+        for (Sample sample : samples) {
+            double total = 0;
+            System.out.print(String.format("%10s  ", sdf.format(new Date(sample.time))));
+            for (int i = 0; i < sample.data.length; i++) {
+                if (sample.data[i] >= 0) {
+                    System.out.print(String.format("%12.2f  ", sample.data[i]));
+                    total += sample.data[i];
+                } else {
+                    System.out.print(String.format("%12s  ", " "));
+                }
+            }
+            System.out.println(String.format("%12.2f", total));
+        }
+    }
+
+}
diff --git a/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfStartCommand.java b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfStartCommand.java
new file mode 100644
index 0000000..bdaf5f9
--- /dev/null
+++ b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfStartCommand.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.onosproject.primitiveperf;
+
+import org.apache.karaf.shell.commands.Command;
+import org.onosproject.cli.AbstractShellCommand;
+
+/**
+ * Starts primitive performance test run.
+ */
+@Command(scope = "onos", name = "primitive-perf-start",
+        description = "Starts primitive performance test run")
+public class PrimitivePerfStartCommand extends AbstractShellCommand {
+
+    @Override
+    protected void execute() {
+        get(PrimitivePerfApp.class).start();
+    }
+
+}
diff --git a/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfStopCommand.java b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfStopCommand.java
new file mode 100644
index 0000000..241bab3
--- /dev/null
+++ b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/PrimitivePerfStopCommand.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.onosproject.primitiveperf;
+
+import org.apache.karaf.shell.commands.Command;
+import org.onosproject.cli.AbstractShellCommand;
+
+/**
+ * Stops primitive performance test run.
+ */
+@Command(scope = "onos", name = "primitive-perf-stop",
+        description = "Stops primitive performance test run")
+public class PrimitivePerfStopCommand extends AbstractShellCommand {
+
+    @Override
+    protected void execute() {
+        get(PrimitivePerfApp.class).stop();
+    }
+
+}
diff --git a/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/package-info.java b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/package-info.java
new file mode 100644
index 0000000..d9b4fae
--- /dev/null
+++ b/apps/test/primitive-perf/src/main/java/org/onosproject/primitiveperf/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2017-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Performance test application for the primitives.
+ */
+package org.onosproject.primitiveperf;
diff --git a/apps/test/primitive-perf/src/main/resources/OSGI-INF/blueprint/shell-config.xml b/apps/test/primitive-perf/src/main/resources/OSGI-INF/blueprint/shell-config.xml
new file mode 100644
index 0000000..a101d45
--- /dev/null
+++ b/apps/test/primitive-perf/src/main/resources/OSGI-INF/blueprint/shell-config.xml
@@ -0,0 +1,28 @@
+<!--
+  ~ Copyright 2017-present Open Networking Laboratory
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<blueprint xmlns="http://www.osgi.org/xmlns/blueprint/v1.0.0">
+    <command-bundle xmlns="http://karaf.apache.org/xmlns/shell/v1.1.0">
+        <command>
+            <action class="org.onosproject.primitiveperf.PrimitivePerfListCommand"/>
+        </command>
+        <command>
+            <action class="org.onosproject.primitiveperf.PrimitivePerfStartCommand"/>
+        </command>
+        <command>
+            <action class="org.onosproject.primitiveperf.PrimitivePerfStopCommand"/>
+        </command>
+    </command-bundle>
+</blueprint>