Skip to content

Commit ca3037a

Browse files
30-day geometric mean trend on the home page (#97)
1 parent a3ee912 commit ca3037a

10 files changed

Lines changed: 371 additions & 1 deletion

File tree

‎.github/workflows/test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ jobs:
3737
with:
3838
python-version: ${{ matrix.python-version }}
3939
- run: pip install -r requirements.txt "django==${{ matrix.django }}.*"
40-
- run: python manage.py test codespeed
40+
- run: python manage.py test codespeed speed_python

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ build
1212
dist
1313
*.egg-info
1414
.venv*/
15+
speed_python/local_settings.py

‎speed_python/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,11 @@ Please, also refer to the [Django URL dispatcher docu]
130130
### Codespeed settings
131131
The main config file is `settings.py`. There you configure everything related
132132
to your set up.
133+
134+
### Home page trend
135+
`HOME_GEOMEAN_SERIES` in `settings.py` lists the executable/environment pairs
136+
plotted on the home page as a trend of the geometric mean of all benchmarks
137+
over the last 30 days. Each line is normalised to its first run in the window,
138+
so 0.95 reads as "5% faster than at the start of the window". The view lives
139+
in `speed_python/views.py`, the chart in `static/js/home_geomean.js`, and the
140+
tests run with `python manage.py test speed_python`.

‎speed_python/settings.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,16 @@ def process_exception(self, request, exception):
118118
# Codespeed settings that can be overwritten here.
119119
from codespeed.settings import *
120120

121+
HOME_GEOMEAN_SERIES = [
122+
{'executable': 'lto-pgo',
123+
'project': 'CPython',
124+
'branch': 'main',
125+
'environment':
126+
'speed-python'},
127+
{'executable': 'lto-pgo',
128+
'project': 'CPython',
129+
'branch': 'main',
130+
'environment': 'ampere-one'},
131+
]
132+
121133
from .local_settings import *

‎speed_python/static/css/home.css‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/* speed.python.org home page: geometric mean trend */
2+
3+
div#presentation div#geomean, div#presentation div#geomean > div { display: block; }
4+
div#geomean {
5+
max-width: 900px;
6+
margin: 1.5em auto 0;
7+
text-align: center;
8+
}
9+
div#geomean-plot { width: 100%; height: 320px; }
10+
div#geomean-legend { min-height: 3.2em; margin: 0.4em 0 0 4em; font-size: smaller; text-align: left; }
11+
div#geomean p.plot-caption { text-align: center; }
12+
/* The plot area starts ~56px in (y-axis label and ticks) and ends ~5px before
13+
the right edge, so centre the heading, legend and caption on the plot area
14+
rather than on the box */
15+
div#geomean h3, div#geomean p.plot-caption { padding-left: 51px; }
16+
/* codespeed's generic div rule gives the legend's colour markers
17+
vertical-align: middle, which lifts them off the label line */
18+
div#geomean-legend .dygraph-legend-line { vertical-align: baseline; margin-right: 0.25em; }
19+
div#geomean p.plot-caption { font-size: 0.9em; color: var(--text-muted); }
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/* Home page trend of the geometric mean of all benchmarks, one line per
2+
* configured executable/environment pair (see HOME_GEOMEAN_SERIES).
3+
*
4+
* Reads its configuration from data attributes on #geomean-plot:
5+
* data-url JSON endpoint (gethomegeomeandata)
6+
* data-changes-url changes page, used when a point is clicked
7+
*/
8+
(function() {
9+
var COLORS = ['#4e79a7', '#f28e2b', '#59a14f', '#e15759', '#76b7b2',
10+
'#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
11+
12+
function parseDate(s) {
13+
// "YYYY/MM/DD HH:MM:SS +0000" -> Date (UTC)
14+
var m = /^(\d+)\/(\d+)\/(\d+) (\d+):(\d+):(\d+)/.exec(s);
15+
return new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
16+
}
17+
18+
function render(container, data) {
19+
var series = (data.series || []).filter(function(s) { return s.points.length; });
20+
if (!series.length) {
21+
container.innerHTML = '<p class="warning">No results in the last ' +
22+
data.days + ' days.</p>';
23+
return;
24+
}
25+
26+
// Merge all series on a shared time axis; dygraphs wants one row per x.
27+
var rowsByTime = {};
28+
var commits = {}; // label -> time -> {commitid, benchmarks}
29+
series.forEach(function(s, i) {
30+
commits[s.label] = {};
31+
s.points.forEach(function(p) {
32+
var t = parseDate(p[0]).getTime();
33+
if (!rowsByTime[t]) {
34+
rowsByTime[t] = new Array(series.length + 1);
35+
rowsByTime[t][0] = new Date(t);
36+
}
37+
rowsByTime[t][i + 1] = p[1];
38+
commits[s.label][t] = {commitid: p[2], benchmarks: p[3]};
39+
});
40+
});
41+
var rows = Object.keys(rowsByTime).sort(function(a, b) { return a - b; })
42+
.map(function(t) { return rowsByTime[t]; });
43+
for (var r = 0; r < rows.length; r++) {
44+
for (var c = 1; c < rows[r].length; c++) {
45+
if (rows[r][c] === undefined) { rows[r][c] = null; }
46+
}
47+
}
48+
49+
var labels = ['Date'].concat(series.map(function(s) { return s.label; }));
50+
51+
new Dygraph(container, rows, {
52+
labels: labels,
53+
colors: COLORS.slice(0, series.length),
54+
connectSeparatedPoints: true,
55+
drawPoints: true,
56+
pointSize: 2,
57+
strokeWidth: 1.5,
58+
highlightCircleSize: 4,
59+
legend: 'always',
60+
labelsDiv: document.getElementById('geomean-legend'),
61+
labelsSeparateLines: true,
62+
ylabel: 'Relative to ' + data.days + ' days ago',
63+
axes: {
64+
y: { axisLabelFormatter: function(v) { return v.toFixed(2); } }
65+
},
66+
valueFormatter: function(v, opts, name, g, row) {
67+
if (name === 'Date') { return new Date(v).toISOString().slice(0, 16).replace('T', ' '); }
68+
if (v === null || v === undefined) { return ''; }
69+
var t = g.getValue(row, 0);
70+
var c = commits[name] && commits[name][t];
71+
return v.toFixed(3) + 'x' + (c ? ' (' + c.commitid.slice(0, 10) + ', ' +
72+
c.benchmarks + ' benchmarks)' : '');
73+
},
74+
pointClickCallback: function(e, point) {
75+
var c = commits[point.name] && commits[point.name][point.xval];
76+
var s = series.filter(function(x) { return x.label === point.name; })[0];
77+
if (!c || !s) { return; }
78+
window.location = container.getAttribute('data-changes-url') +
79+
'?rev=' + encodeURIComponent(c.commitid) +
80+
'&exe=' + s.executable + '&env=' + s.environment;
81+
}
82+
});
83+
}
84+
85+
document.addEventListener('DOMContentLoaded', function() {
86+
var container = document.getElementById('geomean-plot');
87+
if (!container) { return; }
88+
var xhr = new XMLHttpRequest();
89+
xhr.open('GET', container.getAttribute('data-url'));
90+
xhr.onload = function() {
91+
if (xhr.status !== 200) {
92+
container.innerHTML = '<p class="warning">Error retrieving data</p>';
93+
return;
94+
}
95+
render(container, JSON.parse(xhr.responseText));
96+
};
97+
xhr.onerror = xhr.onload;
98+
xhr.send();
99+
});
100+
})();

‎speed_python/templates/home.html‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
{% extends "codespeed/base_site.html" %}
2+
{% load static %}
3+
4+
{% block extra_head %}
5+
<link rel="stylesheet" type="text/css" href="{% static 'css/dygraph.css' %}" />
6+
<link rel="stylesheet" type="text/css" href="{% static 'css/home.css' %}" />
7+
{% endblock %}
28

39
{% block navigation %}
410
{% endblock navigation %}
@@ -24,12 +30,26 @@ <h2>Comparison</h2>
2430
</div>
2531
</a>
2632
<br />
33+
<div id="geomean">
34+
<h3>How has performance changed over the last 30 days?</h3>
35+
<div id="geomean-plot"
36+
data-url="{% url 'gethomegeomeandata' %}"
37+
data-changes-url="{% url 'changes' %}"></div>
38+
<div id="geomean-legend"></div>
39+
<p class="plot-caption">
40+
Geometric mean of all benchmark times, relative to the first run in the
41+
window. Lower is faster. Each line is one executable on one machine;
42+
click a point to open that commit's results.
43+
</p>
44+
</div>
2745
<div id="reports"></div>
2846
</div>
2947
{% endblock body %}
3048

3149
{% block extra_script %}
3250
{{ block.super }}
51+
<script type="text/javascript" src="{% static 'js/dygraph.min.js' %}"></script>
52+
<script type="text/javascript" src="{% static 'js/home_geomean.js' %}"></script>
3353
<script type="text/javascript">
3454
function permalinkToChanges(permalink) {
3555
window.location=permalink;

‎speed_python/tests.py‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Tests for the speed.python.org specific views.
2+
3+
Run with: python manage.py test codespeed speed_python
4+
"""
5+
import json
6+
import os
7+
from datetime import timedelta
8+
9+
from django.test import TestCase, override_settings
10+
from django.urls import reverse
11+
from django.utils import timezone
12+
13+
from codespeed.models import (Project, Benchmark, Revision, Branch, Executable,
14+
Environment, Result)
15+
16+
17+
DUMMY_CACHE = {'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}}
18+
SITE_DIR = os.path.dirname(os.path.abspath(__file__))
19+
# The test settings belong to sample_project; render this site's templates instead.
20+
SITE_TEMPLATES = [{
21+
'BACKEND': 'django.template.backends.django.DjangoTemplates',
22+
'DIRS': [os.path.join(SITE_DIR, 'templates')],
23+
'APP_DIRS': True,
24+
'OPTIONS': {'context_processors': [
25+
'django.template.context_processors.request',
26+
'django.contrib.auth.context_processors.auth',
27+
'django.contrib.messages.context_processors.messages',
28+
]},
29+
}]
30+
31+
32+
@override_settings(
33+
ROOT_URLCONF='speed_python.urls',
34+
TEMPLATES=SITE_TEMPLATES,
35+
CACHES=DUMMY_CACHE,
36+
HOME_GEOMEAN_SERIES=[{'executable': 'myexe', 'environment': 'env1'}])
37+
class TestHomeGeomean(TestCase):
38+
39+
def setUp(self):
40+
self.env = Environment.objects.create(name='env1')
41+
self.project = Project.objects.create(name='proj', default_branch='main')
42+
self.branch = Branch.objects.create(name='main', project=self.project)
43+
self.exe = Executable.objects.create(name='myexe', project=self.project)
44+
self.b1 = Benchmark.objects.create(name='b1')
45+
self.b2 = Benchmark.objects.create(name='b2')
46+
now = timezone.now()
47+
runs = [
48+
# days ago, {benchmark: value}
49+
(45, {self.b1: 9.0, self.b2: 9.0}), # outside the window
50+
(20, {self.b1: 1.0, self.b2: 4.0}), # first in window: baseline
51+
(10, {self.b1: 2.0, self.b2: 2.0}), # geomean of 2.0 and 0.5 -> 1.0
52+
(5, {self.b1: 0.5}), # b2 missing: only b1 counts
53+
]
54+
for i, (days_ago, values) in enumerate(runs):
55+
rev = Revision.objects.create(
56+
commitid='c%d' % i, branch=self.branch, project=self.project,
57+
date=now - timedelta(days=days_ago))
58+
for bench, value in values.items():
59+
Result.objects.create(value=value, revision=rev, executable=self.exe,
60+
benchmark=bench, environment=self.env)
61+
62+
def get(self):
63+
response = self.client.get(reverse('gethomegeomeandata'))
64+
self.assertEqual(response.status_code, 200)
65+
return json.loads(response.content.decode())
66+
67+
def test_series_normalised_to_first_revision_in_window(self):
68+
data = self.get()
69+
self.assertEqual(data['days'], 30)
70+
self.assertEqual(len(data['series']), 1)
71+
series = data['series'][0]
72+
self.assertEqual(series['label'], 'myexe @ env1')
73+
self.assertEqual(series['executable'], self.exe.id)
74+
self.assertEqual(series['environment'], self.env.id)
75+
self.assertEqual(series['branch'], 'main')
76+
self.assertEqual(series['baseline_commitid'], 'c1')
77+
self.assertEqual([p[2] for p in series['points']], ['c1', 'c2', 'c3'])
78+
self.assertEqual([p[1] for p in series['points']], [1.0, 1.0, 0.5])
79+
self.assertEqual([p[3] for p in series['points']], [2, 2, 1])
80+
81+
def test_branch_filter(self):
82+
other = Branch.objects.create(name='other', project=self.project)
83+
rev = Revision.objects.create(commitid='x', branch=other, project=self.project,
84+
date=timezone.now())
85+
Result.objects.create(value=100.0, revision=rev, executable=self.exe,
86+
benchmark=self.b1, environment=self.env)
87+
commits = [p[2] for p in self.get()['series'][0]['points']]
88+
self.assertNotIn('x', commits)
89+
90+
@override_settings(HOME_GEOMEAN_SERIES=[
91+
{'executable': 'myexe', 'environment': 'env1', 'branch': 'main'},
92+
{'executable': 'nope', 'environment': 'env1'},
93+
{'executable': 'myexe', 'environment': 'nope'}])
94+
def test_unknown_entries_are_skipped(self):
95+
data = self.get()
96+
self.assertEqual([s['label'] for s in data['series']], ['myexe @ env1'])
97+
98+
@override_settings(HOME_GEOMEAN_SERIES=[])
99+
def test_unconfigured(self):
100+
self.assertEqual(self.get()['series'], [])
101+
102+
def test_home_page_embeds_the_chart(self):
103+
response = self.client.get(reverse('home'))
104+
self.assertEqual(response.status_code, 200)
105+
self.assertIn('id="geomean-plot"', response.content.decode())
106+
self.assertIn(reverse('gethomegeomeandata'), response.content.decode())

‎speed_python/urls.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99

1010
admin.autodiscover()
1111

12+
from . import views
13+
1214
urlpatterns = [
1315
re_path(r'^admin/', admin.site.urls),
16+
re_path(r'^home/geomean/json/$', views.gethomegeomeandata,
17+
name='gethomegeomeandata'),
1418
re_path(r'^', include('codespeed.urls')),
1519
]
1620

0 commit comments

Comments
 (0)