Skip to content

Commit 536a29b

Browse files
committedAug 19, 2024
v0.4 update
1 parent 50c087a commit 536a29b

19 files changed

+1762
-570
lines changed
 

‎.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
.idea/
33
clustersData/
44
testData/
5-
error_logs/
5+
error_logs_archived/
66
support_files/
7+
frontend/templates/plotly*
8+
outputs/*
79

810
# Byte-compiled / optimized / DLL files
911
__pycache__/

‎GreenAlgorithms_global.py

-443
This file was deleted.

‎__init__.py

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
2+
import argparse
3+
import datetime
4+
import os
5+
6+
from backend import main_backend
7+
from frontend import main_frontend
8+
9+
def create_arguments():
10+
"""
11+
Command line arguments for the tool.
12+
:return: argparse object
13+
"""
14+
parser = argparse.ArgumentParser(description=f'Calculate your carbon footprint on the server.')
15+
16+
default_endDay = datetime.date.today().strftime("%Y-%m-%d") # today
17+
default_startDay = f"{datetime.date.today().year}-01-01" # start of the year
18+
19+
## Timeframe
20+
parser.add_argument('-S', '--startDay', type=str,
21+
help=f'The first day to take into account, as YYYY-MM-DD (default: {default_startDay})',
22+
default=default_startDay)
23+
parser.add_argument('-E', '--endDay', type=str,
24+
help='The last day to take into account, as YYYY-MM-DD (default: today)',
25+
default=default_endDay)
26+
27+
## How to display the report
28+
parser.add_argument('-o', '--output', type=str,
29+
help="How to display the results, one of 'terminal' or 'html' (default: terminal)",
30+
default='terminal')
31+
parser.add_argument('--outputDir', type=str,
32+
help="Export path for the output (default: under `output/`). Only used with `--output html`.",
33+
default='outputs')
34+
35+
## Filter out jobs
36+
parser.add_argument('--filterCWD', action='store_true',
37+
help='Only report on jobs launched from the current location.')
38+
parser.add_argument('--userCWD', type=str, help=argparse.SUPPRESS)
39+
parser.add_argument('--filterJobIDs', type=str,
40+
help='Comma separated list of Job IDs you want to filter on. (default: "all")',
41+
default='all')
42+
parser.add_argument('--filterAccount', type=str,
43+
help='Only consider jobs charged under this account')
44+
parser.add_argument('--customSuccessStates', type=str, default='',
45+
help="Comma-separated list of job states. By default, only jobs that exit with status CD or \
46+
COMPLETED are considered successful (PENDING, RUNNING and REQUEUD are ignored). \
47+
Jobs with states listed here will be considered successful as well (best to list both \
48+
2-letter and full-length codes. Full list of job states: \
49+
https://slurm.schedmd.com/squeue.html#SECTION_JOB-STATE-CODES")
50+
51+
## Reporting bugs
52+
group1 = parser.add_mutually_exclusive_group()
53+
group1.add_argument('--reportBug', action='store_true',
54+
help='In case of a bug, this flag exports the jobs logs so that you/we can investigate further. '
55+
'The debug file will be stored in the shared folder where this tool is located (under /outputs), '
56+
'to export it to your home folder, user `--reportBugHere`. '
57+
'Note that this will write out some basic information about your jobs, such as runtime, '
58+
'number of cores and memory usage.'
59+
)
60+
group1.add_argument('--reportBugHere', action='store_true',
61+
help='Similar to --reportBug, but exports the output to your home folder.')
62+
group2 = parser.add_mutually_exclusive_group()
63+
group2.add_argument('--useCustomLogs', type=str, default='',
64+
help='This bypasses the workload manager, and enables you to input a custom log file of your jobs. \
65+
This is mostly meant for debugging, but can be useful in some situations. '
66+
'An example of the expected file can be found at `example_files/example_sacctOutput_raw.txt`.')
67+
# Arguments for debugging only (not visible to users)
68+
# To ue arbitrary folder for the infrastructure information
69+
parser.add_argument('--useOtherInfrastuctureInfo', type=str, default='', help=argparse.SUPPRESS)
70+
# Uses mock aggregated usage data, for offline debugging
71+
group2.add_argument('--use_mock_agg_data', action='store_true', help=argparse.SUPPRESS)
72+
73+
args = parser.parse_args()
74+
return args
75+
76+
class validate_args():
77+
"""
78+
Class used to validate all the arguments provided.
79+
"""
80+
# TODO add validation
81+
# TODO test these
82+
83+
def _validate_dates(self, args):
84+
"""
85+
Validates that `startDay` and `endDay` are in the right format and in the right order.
86+
"""
87+
for x in [args.startDay, args.endDay]:
88+
try:
89+
datetime.datetime.strptime(x, '%Y-%m-%d')
90+
except ValueError:
91+
raise ValueError(f"Incorrect date format, should be YYYY-MM-DD but is: {x}")
92+
93+
foo = datetime.datetime.strptime(args.startDay, '%Y-%m-%d')
94+
bar = datetime.datetime.strptime(args.endDay, '%Y-%m-%d')
95+
if foo > bar:
96+
raise ValueError(f"Start date ({args.startDay}) is after the end date ({args.endDay}).")
97+
98+
def _validate_output(self, args):
99+
"""
100+
Validates that --output is one of the accepted options.
101+
"""
102+
list_options = ['terminal', 'html']
103+
if args.output not in list_options:
104+
raise ValueError(f"output argument invalid. Is {args.output} but should be one of {list_options}")
105+
106+
107+
def all(self, args):
108+
self._validate_dates(args)
109+
self._validate_output(args)
110+
111+
if __name__ == "__main__":
112+
print("Working dir0: ", os.getcwd()) # DEBUGONLY
113+
114+
args = create_arguments()
115+
116+
## Decide which infrastructure info to use
117+
if args.useOtherInfrastuctureInfo != '':
118+
args.path_infrastucture_info = args.useOtherInfrastuctureInfo
119+
print(f"Overriding infrastructure info with: {args.path_infrastucture_info}")
120+
else:
121+
args.path_infrastucture_info = 'data'
122+
123+
## Organise the unique output directory (used for output report and logs export for debugging)
124+
## creating a uniquely named subdirectory in whatever
125+
# Decide if an output directory is needed at all
126+
if (args.output in ['html']) | args.reportBug | args.reportBugHere:
127+
timestamp = datetime.datetime.now().strftime('%Y%m%d-%H%M-%S%f')
128+
args.outputDir2use = {
129+
'timestamp': timestamp,
130+
'path': os.path.join(args.outputDir, f"outputs_{timestamp}")
131+
}
132+
133+
# Create directory
134+
os.makedirs(args.outputDir2use["path"])
135+
136+
else:
137+
# no output is created
138+
args.outputDir2use = None
139+
140+
### Set the WD to filter on, if needed
141+
if args.filterCWD:
142+
args.filterWD = args.userCWD
143+
print("\nNB: --filterCWD doesn't work with symbolic links (yet!)\n")
144+
else:
145+
args.filterWD = None
146+
147+
### Validate input
148+
validate_args().all(args)
149+
150+
### Run backend to get data
151+
extracted_data = main_backend(args)
152+
153+
main_frontend(extracted_data, args)

‎backend/__init__.py

+265
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
2+
import os
3+
import yaml
4+
import pandas as pd
5+
import numpy as np
6+
7+
from backend.helpers import check_empty_results, simulate_mock_jobs
8+
from backend.slurm_extract import WorkloadManager
9+
10+
# print("Working dir1: ", os.getcwd()) # DEBUGONLY
11+
12+
class GA_tools():
13+
14+
def __init__(self, cluster_info, fParams):
15+
self.cluster_info = cluster_info
16+
self.fParams = fParams
17+
18+
def calculate_energies(self, row):
19+
'''
20+
Calculate the energy usaged based on the job's paramaters
21+
:param row: [pd.Series] one row of usage statistics, corresponding to one job
22+
:return: [pd.Series] the same statistics with the energies added
23+
'''
24+
### CPU and GPU
25+
partition_info = self.cluster_info['partitions'][row.PartitionX]
26+
if row.PartitionTypeX == 'CPU':
27+
TDP2use4CPU = partition_info['TDP']
28+
TDP2use4GPU = 0
29+
else:
30+
TDP2use4CPU = partition_info['TDP_CPU']
31+
TDP2use4GPU = partition_info['TDP']
32+
33+
row['energy_CPUs'] = row.TotalCPUtime2useX.total_seconds() / 3600 * TDP2use4CPU / 1000 # in kWh
34+
35+
row['energy_GPUs'] = row.TotalGPUtime2useX.total_seconds() / 3600 * TDP2use4GPU / 1000 # in kWh
36+
37+
### memory
38+
for suffix, memory2use in zip(['','_memoryNeededOnly'], [row.ReqMemX,row.NeededMemX]):
39+
row[f'energy_memory{suffix}'] = row.WallclockTimeX.total_seconds()/3600 * memory2use * self.fParams['power_memory_perGB'] /1000 # in kWh
40+
row[f'energy{suffix}'] = (row.energy_CPUs + row.energy_GPUs + row[f'energy_memory{suffix}']) * self.cluster_info['PUE'] # in kWh
41+
42+
return row
43+
44+
def calculate_carbonFootprint(self, df, col_energy):
45+
return df[col_energy] * self.cluster_info['CI']
46+
47+
48+
def extract_data(args, cluster_info):
49+
50+
if args.use_mock_agg_data: # DEBUGONLY
51+
52+
if args.reportBug | args.reportBugHere:
53+
print("\n(!) --reportBug and --reportBugHere are ignored when --useCustomLogs is present\n")
54+
55+
# df2 = simulate_mock_jobs()
56+
# df2.to_pickle("testData/df_agg_X_mockMultiUsers_1.pkl")
57+
58+
# foo = 'testData/df_agg_test_3.pkl'
59+
foo = 'testData/df_agg_X_1.pkl'
60+
print(f"Overriding df_agg with `{foo}`")
61+
return pd.read_pickle(foo)
62+
63+
64+
### Pull usage statistics from the workload manager
65+
WM = WorkloadManager(args, cluster_info)
66+
WM.pull_logs()
67+
68+
### Log the output for debugging
69+
# TODO cleanup file/dir management here
70+
scripts_dir = os.path.dirname(os.path.realpath(__file__))
71+
if args.reportBug | args.reportBugHere:
72+
73+
# log_name = str(datetime.datetime.now().timestamp()).replace(".", "_")
74+
75+
if args.reportBug:
76+
# Create an error_logs subfolder in the output dir
77+
errorLogsDir = os.path.join(args.outputDir2use['path'], 'error_logs')
78+
os.makedirs(errorLogsDir)
79+
log_path = os.path.join(errorLogsDir, f'sacctOutput.csv')
80+
else:
81+
# i.e. args.reportBugHere is True
82+
log_path = f"{args.userCWD}/sacctOutput_{args.outputDir2use['timestamp']}.csv"
83+
84+
with open(log_path, 'wb') as f:
85+
f.write(WM.logs_raw)
86+
print(f"\nSLURM statistics logged for debuging: {log_path}\n")
87+
88+
### Turn usage logs into DataFrame
89+
WM.convert2dataframe()
90+
91+
# And clean
92+
WM.clean_logs_df()
93+
# Check if there are any jobs during the period from this directory and with these jobIDs
94+
check_empty_results(WM.df_agg, args)
95+
96+
# Check that there is only one user's data
97+
if len(set(WM.df_agg_X.UserX)) > 1:
98+
raise ValueError(f"More than one user's logs was included: {set(WM.df_agg_X.UserX)}")
99+
100+
# WM.df_agg_X.to_pickle("testData/df_agg_X_1.pkl") # DEBUGONLY used to test different steps offline
101+
102+
return WM.df_agg_X
103+
104+
def enrich_data(df, fParams, GA):
105+
106+
### energy
107+
df = df.apply(GA.calculate_energies, axis=1)
108+
109+
df['energy_failedJobs'] = np.where(df.StateX == 0, df.energy, 0)
110+
111+
### carbon footprint
112+
for suffix in ['', '_memoryNeededOnly', '_failedJobs']:
113+
df[f'carbonFootprint{suffix}'] = GA.calculate_carbonFootprint(df, f'energy{suffix}')
114+
# Context metrics (part 1)
115+
df[f'treeMonths{suffix}'] = df[f'carbonFootprint{suffix}'] / fParams['tree_month']
116+
df[f'cost{suffix}'] = df[f'energy{suffix}'] * fParams['electricity_cost'] # TODO use realtime electricity costs
117+
118+
### Context metrics (part 2)
119+
df['driving'] = df.carbonFootprint / fParams['passengerCar_EU_perkm']
120+
df['flying_NY_SF'] = df.carbonFootprint / fParams['flight_NY_SF']
121+
df['flying_PAR_LON'] = df.carbonFootprint / fParams['flight_PAR_LON']
122+
df['flying_NYC_MEL'] = df.carbonFootprint / fParams['flight_NYC_MEL']
123+
124+
return df
125+
126+
def summarise_data(df, args):
127+
agg_functions_from_raw = {
128+
'n_jobs': ('UserX', 'count'),
129+
'first_job_period': ('SubmitDatetimeX', 'min'),
130+
'last_job_period': ('SubmitDatetimeX', 'max'),
131+
'energy': ('energy', 'sum'),
132+
'energy_CPUs': ('energy_CPUs', 'sum'),
133+
'energy_GPUs': ('energy_GPUs', 'sum'),
134+
'energy_memory': ('energy_memory', 'sum'),
135+
'carbonFootprint': ('carbonFootprint', 'sum'),
136+
'carbonFootprint_memoryNeededOnly': ('carbonFootprint_memoryNeededOnly', 'sum'),
137+
'carbonFootprint_failedJobs': ('carbonFootprint_failedJobs', 'sum'),
138+
'cpuTime': ('TotalCPUtime2useX', 'sum'),
139+
'gpuTime': ('TotalGPUtime2useX', 'sum'),
140+
'wallclockTime': ('WallclockTimeX', 'sum'),
141+
'CPUhoursCharged': ('CPUhoursChargedX', 'sum'),
142+
'GPUhoursCharged': ('GPUhoursChargedX', 'sum'),
143+
'memoryRequested': ('ReqMemX', 'sum'),
144+
'memoryOverallocationFactor': ('memOverallocationFactorX', 'mean'),
145+
'n_success': ('StateX', 'sum'),
146+
'treeMonths': ('treeMonths', 'sum'),
147+
'treeMonths_memoryNeededOnly': ('treeMonths_memoryNeededOnly', 'sum'),
148+
'treeMonths_failedJobs': ('treeMonths_failedJobs', 'sum'),
149+
'driving': ('driving', 'sum'),
150+
'flying_NY_SF': ('flying_NY_SF', 'sum'),
151+
'flying_PAR_LON': ('flying_PAR_LON', 'sum'),
152+
'flying_NYC_MEL': ('flying_NYC_MEL', 'sum'),
153+
'cost': ('cost', 'sum'),
154+
'cost_failedJobs': ('cost_failedJobs', 'sum'),
155+
'cost_memoryNeededOnly': ('cost_memoryNeededOnly', 'sum'),
156+
}
157+
158+
# This is to aggregate already aggregated dataset (so names are a bit different)
159+
agg_functions_further = agg_functions_from_raw.copy()
160+
agg_functions_further['n_jobs'] = ('n_jobs', 'sum')
161+
agg_functions_further['first_job_period'] = ('first_job_period', 'min')
162+
agg_functions_further['last_job_period'] = ('last_job_period', 'max')
163+
agg_functions_further['cpuTime'] = ('cpuTime', 'sum')
164+
agg_functions_further['gpuTime'] = ('gpuTime', 'sum')
165+
agg_functions_further['wallclockTime'] = ('wallclockTime', 'sum')
166+
agg_functions_further['CPUhoursCharged'] = ('CPUhoursCharged', 'sum')
167+
agg_functions_further['GPUhoursCharged'] = ('GPUhoursCharged', 'sum')
168+
agg_functions_further['memoryRequested'] = ('memoryRequested', 'sum')
169+
agg_functions_further['memoryOverallocationFactor'] = ('memoryOverallocationFactor', 'mean') # NB: not strictly correct to do a mean of mean, but ok
170+
agg_functions_further['n_success'] = ('n_success', 'sum')
171+
172+
def agg_jobs(data, agg_names=None):
173+
"""
174+
175+
:param data:
176+
:param agg_names: if None, then the whole dataset is aggregated
177+
:return:
178+
"""
179+
agg_names2 = agg_names if agg_names else lambda _:True
180+
if 'UserX' in data.columns:
181+
timeseries = data.groupby(agg_names2).agg(**agg_functions_from_raw)
182+
else:
183+
timeseries = data.groupby(agg_names2).agg(**agg_functions_further)
184+
185+
timeseries.reset_index(inplace=True, drop=(agg_names is None))
186+
timeseries['success_rate'] = timeseries.n_success / timeseries.n_jobs
187+
timeseries['failure_rate'] = 1 - timeseries.success_rate
188+
timeseries['share_carbonFootprint'] = timeseries.carbonFootprint / timeseries.carbonFootprint.sum()
189+
190+
return timeseries
191+
192+
df['SubmitDate'] = df.SubmitDatetimeX.dt.date # TODO do it with real start time rather than submit day
193+
194+
df_userdaily = agg_jobs(df, ['SubmitDate'])
195+
df_overallStats = agg_jobs(df_userdaily)
196+
dict_overallStats = df_overallStats.iloc[0, :].to_dict()
197+
userID = df.UserX[0]
198+
199+
output = {
200+
"userDaily": df_userdaily,
201+
'userActivity': {userID: dict_overallStats},
202+
"user": userID
203+
}
204+
205+
# Some job-level statistics to plot distributions
206+
memoryOverallocationFactors = df.groupby('UserX')['memOverallocationFactorX'].apply(list).to_dict()
207+
memoryOverallocationFactors['overall'] = df.memOverallocationFactorX.to_numpy()
208+
output['memoryOverallocationFactors'] = memoryOverallocationFactors
209+
210+
return output
211+
212+
213+
def main_backend(args):
214+
'''
215+
216+
:param args:
217+
:return:
218+
'''
219+
### Load cluster specific info
220+
with open(os.path.join(args.path_infrastucture_info, 'cluster_info.yaml'), "r") as stream:
221+
try:
222+
cluster_info = yaml.safe_load(stream)
223+
except yaml.YAMLError as exc:
224+
print(exc)
225+
226+
### Load fixed parameters
227+
with open("data/fixed_parameters.yaml", "r") as stream:
228+
try:
229+
fParams = yaml.safe_load(stream)
230+
except yaml.YAMLError as exc:
231+
print(exc)
232+
233+
GA = GA_tools(cluster_info, fParams)
234+
235+
df = extract_data(args, cluster_info=cluster_info)
236+
df2 = enrich_data(df, fParams=fParams, GA=GA)
237+
summary_stats = summarise_data(df2, args=args)
238+
239+
return summary_stats
240+
241+
if __name__ == "__main__":
242+
243+
#### This is used for testing only ####
244+
245+
from collections import namedtuple
246+
argStruct = namedtuple('argStruct',
247+
'startDay endDay use_mock_agg_data useCustomLogs customSuccessStates filterWD filterJobIDs filterAccount reportBug reportBugHere path_infrastucture_info')
248+
args = argStruct(
249+
startDay='2022-01-01',
250+
endDay='2023-06-30',
251+
useCustomLogs=None,
252+
use_mock_agg_data=True,
253+
customSuccessStates='',
254+
filterWD=None,
255+
filterJobIDs='all',
256+
filterAccount=None,
257+
reportBug=False,
258+
reportBugHere=False,
259+
path_infrastucture_info="clustersData/CSD3",
260+
)
261+
262+
main_backend(args)
263+
264+
265+

‎backend/helpers.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
2+
import datetime
3+
import sys
4+
import random
5+
import pandas as pd
6+
import numpy as np
7+
8+
def check_empty_results(df, args):
9+
"""
10+
This is to check whether any jobs have been run on the period, and stop the script if not.
11+
:param df: [pd.DataFrame] Usage logs
12+
:param args:
13+
"""
14+
if len(df) == 0:
15+
if args.filterWD is not None:
16+
addThat = f' from this directory ({args.filterWD})'
17+
else:
18+
addThat = ''
19+
if args.filterJobIDs != 'all':
20+
addThat += ' and with these jobIDs'
21+
if args.filterAccount is not None:
22+
addThat += ' charged under this account'
23+
24+
print(f'''
25+
26+
You haven't run any jobs on that period (from {args.startDay} to {args.endDay}){addThat}.
27+
28+
''')
29+
sys.exit()
30+
31+
def simulate_mock_jobs(): # DEBUGONLY
32+
df_list = []
33+
n_jobs = random.randint(500,800)
34+
foo = {
35+
'WallclockTimeX':[datetime.timedelta(minutes=random.randint(50,700)) for _ in range(n_jobs)],
36+
'ReqMemX':np.random.randint(4,130, size=n_jobs)*1.,
37+
'PartitionX':['icelake']*n_jobs,
38+
'SubmitDatetimeX':[datetime.datetime(day=1,month=5,year=2023) + datetime.timedelta(days=random.randint(1,60)) for _ in range(n_jobs)],
39+
'StateX':np.random.choice([1,0], p=[.8,.2], size=n_jobs),
40+
'UIDX':['11111']*n_jobs,
41+
'UserX':['foo']*n_jobs,
42+
'PartitionTypeX':['CPU']*n_jobs,
43+
'TotalCPUtime2useX':[datetime.timedelta(minutes=random.randint(50,5000)) for _ in range(n_jobs)],
44+
'TotalGPUtime2useX':[datetime.timedelta(seconds=0)]*n_jobs,
45+
}
46+
47+
foo_df = pd.DataFrame(foo)
48+
foo_df['CPUhoursChargedX'] = foo_df.TotalCPUtime2useX / np.timedelta64(1, 'h')
49+
foo_df['GPUhoursChargedX'] = 0.
50+
foo_df['NeededMemX'] = foo_df.ReqMemX * np.random.random(n_jobs)
51+
foo_df['memOverallocationFactorX'] = foo_df.ReqMemX / foo_df.NeededMemX
52+
53+
df_list.append(foo_df)
54+
return pd.concat(df_list)

‎GreenAlgorithms_workloadManager.py ‎backend/slurm_extract.py

+127-118
Large diffs are not rendered by default.

‎cluster_info.yaml

+12
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
## ~~~ TO BE EDITED TO BE TAILORED TO THE CLUSTER ~~~
33
## Fill in the values for your cluster
44
##
5+
## Updated: 14/12/2023
56
---
7+
institution: "My institution"
68
cluster_name: "My cluster" # [str]
79
granularity_memory_request: 6 # [number] in GB,
810
partitions: # a list of the different partitions on the cluster
@@ -18,6 +20,16 @@ partitions: # a list of the different partitions on the cluster
1820
TDP_CPU: 8
1921
PUE: 1.67 # [number > 1] Power Usage Effectiveness of the facility
2022
CI: 467 # [number] carbon intensity of the geographic location, in gCO2e/kWh
23+
energy_cost:
24+
cost: 0.34 # in currency/kWh
25+
currency: "£"
26+
#
27+
# Below are optional parameters if the dashboard output is used.
28+
# HTML tags can be used
29+
#
30+
texts_intro:
31+
CPU: "XX W/core"
32+
GPU: "e.g. NVIDIA A100 (300 W) and NVIDIA Tesla P100 (250 W)"
2133
#
2234
# Below are optional parameters to accommodate some clusters. Do not remove but can be ignored.
2335
#

‎data/cluster_info.yaml

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
##
2+
## ~~~ TO BE EDITED TO BE TAILORED TO THE CLUSTER ~~~
3+
## Fill in the values for your cluster: all the variables in <> need to be changed
4+
##
5+
---
6+
cluster_name: "<My cluster name>" # [str]
7+
granularity_memory_request: <6> # [number] in GB representing the smallest memory unit users can reserve
8+
partitions: # a list of the different partitions on the cluster
9+
<partition name_1>: # name of the partition
10+
type: <CPU> # [CPU or GPU]
11+
model: "<Intel XXX>" # [str] the model of the processing core on this partition. Not actually used by the code but useful for reference for others.
12+
TDP: <10> # [number] TDP of the processor, in W, per core
13+
<partition name_2>: # name of the partition
14+
type: <GPU> # [CPU or GPU]
15+
model: "<NVIDIA XXX>" # [str] the model of the processing core on this partition. Not actually used by the code but useful for reference for others.
16+
TDP: <250> # [number] For GPUs, the TDP is for the entire GPU
17+
# For GPU partitions, we also need info about the CPUs available for support.
18+
model_CPU: "<Intel XXX>" # [str] Not actually used by the code but useful for reference for others.
19+
TDP_CPU: <10> # [number] TDP of the processor, in W, per core
20+
# You can keep adding partitions to this
21+
PUE: <1.67> # [number > 1] Power Usage Effectiveness of the facility
22+
CI: <467> # [number] average carbon intensity of the geographic location, in gCO2e/kWh
23+
energy_cost:
24+
cost: <0.34> # [number] in currency/kWh
25+
currency: "<£>" # [str]
26+
#
27+
# Below are optional parameters if the html output is used.
28+
# HTML tags can be used
29+
#
30+
texts_intro:
31+
CPU: "XX - XX W/core (see <a>here</a> for models)" # For example
32+
GPU: "NVIDIA A100 (300 W) and NVIDIA Tesla P100 (250 W)" # For example
33+
#
34+
# Below are optional parameters to accommodate some clusters. Do not remove but can be ignored.
35+
#
36+
default_unit_RSS: 'K'

‎data/fixed_parameters.yaml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
## ~~~ DO NOT EDIT ~~~
3+
##
4+
## These are fixed values, from the Green Algorithms app
5+
## Hello World
6+
7+
---
8+
power_memory_perGB: 0.3725 # W/GB
9+
tree_month: 917 #gCO2e
10+
passengerCar_EU_perkm: 175 #gCO2e/km
11+
passengerCar_US_perkm: 251 #gCO2e/km
12+
flight_NY_SF: 570000 #gCO2e
13+
flight_PAR_LON: 50000 #gCO2e
14+
flight_NYC_MEL: 2310000 #gCO2e
15+
electricity_cost: 0.34 # GBP/kWh (source?)

‎fixed_parameters.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
## ~~~ DO NOT EDIT ~~~
33
##
44
## These are fixed values, from the Green Algorithms app
5-
## Hello World
5+
##
66

77
---
88
power_memory_perGB: 0.3725 # W/GB

‎frontend/__init__.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
2+
import yaml
3+
import os
4+
5+
from frontend.terminal_output import generate_terminal_view
6+
from frontend.dashboard_output import dashboard_html
7+
8+
def main_frontend(dict_stats, args):
9+
### Load cluster specific info
10+
with open(os.path.join(args.path_infrastucture_info, 'cluster_info.yaml'), "r") as stream:
11+
try:
12+
cluster_info = yaml.safe_load(stream)
13+
except yaml.YAMLError as exc:
14+
print(exc)
15+
16+
if args.output == 'terminal':
17+
print("Generating terminal view... ", end="")
18+
terminal_view = generate_terminal_view(dict_stats, args, cluster_info)
19+
print("Done\n")
20+
print(terminal_view)
21+
elif args.output == 'html':
22+
print("Generating html... ", end="")
23+
dashboard = dashboard_html(
24+
dict_stats=dict_stats,
25+
args=args,
26+
cluster_info=cluster_info,
27+
)
28+
report_path = dashboard.generate()
29+
print(f"done: {report_path}")
30+
31+
else:
32+
raise ValueError("Wrong output format")
33+
34+
35+
if __name__ == "__main__":
36+
37+
#### This is used for testing only ####
38+
39+
from collections import namedtuple
40+
from backend import main_backend
41+
42+
argStruct = namedtuple('argStruct',
43+
'startDay endDay use_mock_agg_data user output useCustomLogs customSuccessStates filterWD filterJobIDs filterAccount reportBug reportBugHere path_infrastucture_info')
44+
args = argStruct(
45+
startDay='2022-01-01',
46+
endDay='2023-06-30',
47+
use_mock_agg_data=True,
48+
user='ll582',
49+
output='html',
50+
useCustomLogs=None,
51+
customSuccessStates='',
52+
filterWD=None,
53+
filterJobIDs='all',
54+
filterAccount=None,
55+
reportBug=False,
56+
reportBugHere=False,
57+
path_infrastucture_info="clustersData/CSD3",
58+
)
59+
with open(os.path.join(args.path_infrastucture_info, 'cluster_info.yaml'), "r") as stream:
60+
try:
61+
cluster_info = yaml.safe_load(stream)
62+
except yaml.YAMLError as exc:
63+
print(exc)
64+
65+
extracted_data = main_backend(args)
66+
67+
# generate_dashboard_html(dict_stats=extracted_data, args=args, cluster_info=cluster_info, dict_deptGroupsUsers=dict_deptGroupsUsers, dict_users=dict_users)
68+
69+
main_frontend(dict_stats=extracted_data,args=args)

‎frontend/dashboard_output.py

+244
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import os
2+
import shutil
3+
from jinja2 import Environment, FileSystemLoader
4+
# from jinja2 import select_autoescape, DebugUndefined, StrictUndefined, Undefined
5+
import datetime
6+
from pprint import pprint
7+
import pandas as pd
8+
import numpy as np
9+
import plotly.express as px
10+
11+
from frontend.helpers import formatText_footprint, formatText_treemonths, formatText_flying
12+
13+
# class SilentUndefined(Undefined): # DEBUGONLY
14+
# def _fail_with_undefined_error(self, *args, **kwargs):
15+
# return '!MISSING!'
16+
17+
def formatText_timedelta_short(dt):
18+
dt_sec = dt.total_seconds()
19+
hour = 3600
20+
day = 24*hour
21+
year = 365*day
22+
if dt_sec >= year:
23+
return f"{dt_sec / year:.1f} year{'' if int(dt_sec/year)==1 else 's'}"
24+
elif dt_sec > 2*day:
25+
return f"{dt_sec / day:.1f} days"
26+
elif dt_sec >= hour:
27+
return f"{dt_sec / hour:.1f} hour{'' if int(dt_sec/hour)==1 else 's'}"
28+
else:
29+
return f"{dt_sec:.2f} seconds"
30+
31+
def formatText_cost(cost, cluster_info):
32+
return f"{cluster_info['energy_cost']['currency']}{cost:,.0f}"
33+
34+
def get_summary_texts(dict_in, cluster_info):
35+
output = {
36+
'cpuTime': formatText_timedelta_short(dict_in['cpuTime']),
37+
'gpuTime': formatText_timedelta_short(dict_in['gpuTime']),
38+
'carbonFootprint': formatText_footprint(dict_in['carbonFootprint'], use_html=True),
39+
'carbonFootprint_failedJobs': formatText_footprint(dict_in['carbonFootprint_failedJobs'], use_html=True),
40+
'carbonFootprint_failedJobs_share': f"{dict_in['carbonFootprint_failedJobs']/dict_in['carbonFootprint']:.2%}",
41+
'carbonFootprint_wasted_memoryOverallocation': formatText_footprint(dict_in['carbonFootprint']-dict_in['carbonFootprint_memoryNeededOnly'], use_html=True),
42+
'share_carbonFootprint': f"{dict_in['share_carbonFootprint']:.2%}",
43+
'trees': formatText_treemonths(dict_in['treeMonths'], splitMonthsYear=False),
44+
'flying': formatText_flying(dict_in, output_format='dict'),
45+
'cost': formatText_cost(dict_in['cost'], cluster_info=cluster_info),
46+
'cost_failedJobs': formatText_cost(dict_in['cost_failedJobs'], cluster_info=cluster_info),
47+
'cost_wasted_memoryOverallocation': formatText_cost(dict_in['cost']-dict_in['cost_memoryNeededOnly'], cluster_info=cluster_info),
48+
'n_jobs': f"{dict_in['n_jobs']:,}"
49+
}
50+
51+
for key in dict_in:
52+
if key not in output:
53+
# print(f"adding {key}")
54+
output[key] = dict_in[key]
55+
56+
return output
57+
58+
class dashboard_html:
59+
def __init__(self, dict_stats, args, cluster_info):
60+
self.dict_stats = dict_stats
61+
self.args = args
62+
self.cluster_info = cluster_info
63+
64+
self.context = {
65+
'last_updated': datetime.datetime.now().strftime("%A %d %b %Y, %H:%M"),
66+
'startDay': args.startDay,
67+
'endDay': args.endDay,
68+
'institution': cluster_info['institution'],
69+
'cluster_name': cluster_info['cluster_name'],
70+
'PUE': cluster_info['PUE'],
71+
'CI': cluster_info['CI'],
72+
'energy_cost_perkWh': cluster_info['energy_cost'],
73+
'texts_intro': cluster_info['texts_intro'],
74+
}
75+
76+
self.template_plotly = "plotly_white"
77+
self.custom_colours = {
78+
'area': '#a6cee3'
79+
}
80+
self.height_plotly = 350
81+
82+
self.user_here = dict_stats['user']
83+
84+
self.outputDir = args.outputDir2use['path']
85+
self.plotsDir = os.path.join(self.outputDir, 'plots')
86+
os.makedirs(self.plotsDir)
87+
88+
def _user_context(self):
89+
####################################
90+
# User-specific part of the report #
91+
####################################
92+
93+
self.context['user'] = {'userID': self.user_here}
94+
95+
self.context['usersActivity'] = {
96+
self.user_here: get_summary_texts(
97+
self.dict_stats['userActivity'][self.user_here],
98+
cluster_info=self.cluster_info
99+
)
100+
}
101+
102+
### User's overall metrics
103+
104+
df_userDaily_here = self.dict_stats['userDaily']
105+
106+
# Daily carbon footprint
107+
fig_userDailyCarbonFootprint = px.area(
108+
df_userDaily_here, x='SubmitDate', y="carbonFootprint",
109+
labels=dict(SubmitDate='', carbonFootprint='Carbon footprint (gCO2e)'),
110+
title="Daily carbon footprint",
111+
template=self.template_plotly,
112+
color_discrete_sequence=[self.custom_colours['area']]
113+
)
114+
fig_userDailyCarbonFootprint.update_layout(height=self.height_plotly)
115+
fig_userDailyCarbonFootprint.write_html(
116+
os.path.join(self.plotsDir, "plotly_thisuserDailyCarbonFootprint.html"),
117+
include_plotlyjs='cdn'
118+
)
119+
120+
# Daily number of jobs
121+
fig_userDailyNjobs = px.area(
122+
df_userDaily_here, x='SubmitDate', y="n_jobs",
123+
labels=dict(SubmitDate='', n_jobs='Number of jobs started'),
124+
title="Number of jobs started",
125+
template=self.template_plotly,
126+
color_discrete_sequence=[self.custom_colours['area']]
127+
)
128+
fig_userDailyNjobs.update_layout(height=self.height_plotly)
129+
fig_userDailyNjobs.write_html(
130+
os.path.join(self.plotsDir, "plotly_thisuserDailyNjobs.html"),
131+
include_plotlyjs='cdn'
132+
)
133+
134+
# Daily CPU time
135+
fig_userDailyCpuTime = px.area(
136+
df_userDaily_here, x='SubmitDate', y="CPUhoursCharged",
137+
labels=dict(SubmitDate='', CPUhoursCharged='CPU core-hours'),
138+
title="CPU core hours",
139+
template=self.template_plotly,
140+
color_discrete_sequence=[self.custom_colours['area']]
141+
)
142+
fig_userDailyCpuTime.update_layout(height=self.height_plotly)
143+
fig_userDailyCpuTime.write_html(
144+
os.path.join(self.plotsDir, "plotly_thisuserDailyCpuTime.html"),
145+
include_plotlyjs='cdn'
146+
)
147+
148+
# Daily Memory requested
149+
fig_userDailyCpuTime = px.area(
150+
df_userDaily_here, x='SubmitDate', y="memoryRequested",
151+
labels=dict(SubmitDate='', memoryRequested='Memory requested (GB)'),
152+
title="Memory requested",
153+
template=self.template_plotly,
154+
color_discrete_sequence=[self.custom_colours['area']]
155+
)
156+
fig_userDailyCpuTime.update_layout(height=self.height_plotly)
157+
fig_userDailyCpuTime.write_html(
158+
os.path.join(self.plotsDir, "plotly_thisuserDailyMemoryRequested.html"),
159+
include_plotlyjs='cdn'
160+
)
161+
162+
# Total success rate
163+
n_success = self.dict_stats['userActivity'][self.user_here]['n_success']
164+
n_failure = self.dict_stats['userActivity'][self.user_here]['n_jobs'] - self.dict_stats['userActivity'][self.user_here]['n_success']
165+
foo = pd.DataFrame({
166+
'Status': ['Success', 'Failure'],
167+
'Number of jobs': [n_success, n_failure]
168+
})
169+
fig_userSuccessRate = px.pie(
170+
foo, values='Number of jobs', names='Status', color='Status',
171+
color_discrete_map={'Success':"#A9DFBF", 'Failure': "#F5B7B1"},
172+
template=self.template_plotly,
173+
hole=.6,
174+
)
175+
fig_userSuccessRate.update_layout(height=self.height_plotly)
176+
fig_userSuccessRate.write_html(
177+
os.path.join(self.plotsDir, "plotly_thisuserSuccessRate.html"),
178+
include_plotlyjs='cdn'
179+
)
180+
181+
# Daily success rate
182+
fig_userDailySuccessRate = px.area(
183+
pd.melt(df_userDaily_here, id_vars='SubmitDate', value_vars=['failure_rate', 'success_rate']),
184+
x='SubmitDate', y="value", color='variable',
185+
color_discrete_map={'failure_rate': "#F5B7B1", 'success_rate': "#A9DFBF"},
186+
labels=dict(SubmitDate='', value='% of failed jobs (in red)', variable=""),
187+
# title="",
188+
template=self.template_plotly
189+
)
190+
fig_userDailySuccessRate.update_layout(height=self.height_plotly, showlegend=False)
191+
fig_userDailySuccessRate.write_html(
192+
os.path.join(self.plotsDir, "plotly_thisuserDailySuccessRate.html"),
193+
include_plotlyjs='cdn'
194+
)
195+
196+
# Memory efficiency
197+
fig_userMemoryEfficiency = px.histogram(
198+
np.reciprocal(self.dict_stats['memoryOverallocationFactors'][self.user_here]) * 100,
199+
labels=dict(value="Memory efficiency (%)"),
200+
template=self.template_plotly,
201+
color_discrete_sequence=[self.custom_colours['area']]
202+
)
203+
fig_userMemoryEfficiency.update_layout(
204+
bargap=0.2,
205+
yaxis_title="Number of jobs",
206+
showlegend=False,
207+
height=self.height_plotly
208+
)
209+
fig_userMemoryEfficiency.write_html(
210+
os.path.join(self.plotsDir, "plotly_thisuserMemoryEfficiency.html"),
211+
include_plotlyjs='cdn'
212+
)
213+
214+
def generate(self):
215+
216+
self.context['include_user_context'] = True
217+
218+
self._user_context()
219+
220+
environment = Environment(
221+
loader=FileSystemLoader(['frontend/templates/', self.plotsDir]),
222+
# autoescape=select_autoescape(),
223+
# undefined=SilentUndefined # StrictUndefined is mostly for testing, SilenUndefined to ignore missing ones
224+
)
225+
226+
j2_template = environment.get_template('report_blank.html')
227+
j2_rendered = j2_template.render(self.context)
228+
229+
## Export
230+
# print(os.getcwd())
231+
report_path = os.path.join(self.outputDir, f"report_{self.user_here}.html")
232+
with open(report_path, 'w') as file:
233+
file.write(j2_rendered)
234+
# Also copy across the styles.css
235+
shutil.copy("frontend/templates/styles.css", self.outputDir)
236+
237+
return report_path
238+
239+
# FIXME the pdf export doesn't really work...sticking with html for now
240+
# Follows guidelines from https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#command-line
241+
# from weasyprint import HTML, CSS
242+
#
243+
# css = CSS(string=''' @page {size: 53.34cm 167.86 cm;} ''')
244+
# HTML("outputs/report_rendered.html").write_pdf("outputs/report_rendered.pdf", stylesheets=[css])

‎frontend/helpers.py

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
def formatText_footprint(footprint_g, use_html=False):
2+
'''
3+
Format the text to display the carbon footprint
4+
:param footprint_g: [float] carbon footprint, in gCO2e
5+
:return: [str] the text to display
6+
'''
7+
if use_html:
8+
co2e = "CO<sub>2</sub>e"
9+
else:
10+
co2e = "CO2e"
11+
if footprint_g < 1e3:
12+
text_footprint = f"{footprint_g:,.0f} g{co2e}"
13+
elif footprint_g < 1e6:
14+
text_footprint = f"{footprint_g / 1e3:,.0f} kg{co2e}"
15+
else:
16+
text_footprint = f"{footprint_g / 1e3:,.0f} T{co2e}"
17+
return text_footprint
18+
19+
def formatText_treemonths(tm_float, splitMonthsYear=True):
20+
'''
21+
Format the text to display the tree months
22+
:param tm_float: [float] tree-months
23+
:return: [str] the text to display
24+
'''
25+
tm = int(tm_float)
26+
ty = int(tm / 12)
27+
if tm < 1:
28+
text_trees = f"{tm_float:.3f} tree-months"
29+
elif tm == 1:
30+
text_trees = f"{tm_float:.1f} tree-month"
31+
elif tm < 6:
32+
text_trees = f"{tm_float:.1f} tree-months"
33+
elif tm <= 24:
34+
text_trees = f"{tm} tree-months"
35+
elif tm < 120:
36+
if splitMonthsYear:
37+
text_trees = f"{ty} tree-years and {tm - ty * 12} tree-months"
38+
else:
39+
text_trees = f"{ty} tree-years"
40+
else:
41+
text_trees = f"{tm_float/12:.1f} tree-years"
42+
return text_trees
43+
44+
def formatText_flying(dict_stats, output_format='single_str'):
45+
"""
46+
Format the text to display about flying
47+
:param dict_stats:
48+
:param output_format:
49+
:return: [str] or [(float,str)] text to display
50+
"""
51+
if output_format not in ['single_str', 'dict']:
52+
raise ValueError()
53+
54+
if dict_stats['flying_NY_SF'] < 0.5:
55+
value = round(dict_stats['flying_PAR_LON'], 2)
56+
if output_format == 'single_str':
57+
output_flying = f"{value:,} flights between Paris and London"
58+
else:
59+
output_flying = {'number': value, 'trip': 'Paris - London'}
60+
elif dict_stats['flying_NYC_MEL'] < 0.5:
61+
value = round(dict_stats['flying_NY_SF'], 2)
62+
if output_format == 'single_str':
63+
output_flying = f"{value:,} flights between New York and San Francisco"
64+
else:
65+
output_flying = {'number': value, 'trip': 'New York - San Francisco'}
66+
else:
67+
value = round(dict_stats['flying_NYC_MEL'], 2)
68+
if output_format == 'single_str':
69+
output_flying = f"{value:,} flights between New York and Melbourne"
70+
else:
71+
output_flying = {'number': value, 'trip': 'New York - Melbourne'}
72+
return output_flying

‎frontend/templates/_user.html

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<!--TODO create similar pages for department and group granularity -->
2+
3+
<div id="user" class="section scrollspy">
4+
<div>
5+
<h3>User's personal report: {{ user.userID }}</h3>
6+
7+
<p>
8+
Find out your carbon footprint from {{ startDay }} to {{ endDay }}.
9+
</p>
10+
</div>
11+
12+
<div id="summary_user" class="card-panel stats-summary">
13+
<div class="row center-align">
14+
<div class="col s3">
15+
<i class="fa-solid fa-microchip"></i>
16+
<span>CPU time</span>
17+
<span data-stat="cpu" >{{ usersActivity[user.userID].cpuTime }}</span>
18+
</div>
19+
<div class="col s3">
20+
<i class="fa-solid fa-smog"></i>
21+
<span>Carbon footprint</span>
22+
<span data-stat="co2e" >{{ usersActivity[user.userID].carbonFootprint }}</span>
23+
</div>
24+
<div class="col s3">
25+
<i class="fa-solid fa-plane"></i>
26+
<span>{{ usersActivity[user.userID].flying.trip }}</span>
27+
<span data-stat="flight" >{{ usersActivity[user.userID].flying.number }}</span>
28+
</div>
29+
<div class="col s3">
30+
<i class="fa-solid fa-tree"></i>
31+
<span>Carbon sequestration</span>
32+
<span data-stat="tree" >{{ usersActivity[user.userID].trees }}</span>
33+
</div>
34+
</div>
35+
</div>
36+
37+
<div>
38+
<div>
39+
{% include 'plotly_thisuserDailyCarbonFootprint.html' %}
40+
</div>
41+
<div>
42+
{% include 'plotly_thisuserDailyNjobs.html' %}
43+
</div>
44+
<div>
45+
{% include 'plotly_thisuserDailyCpuTime.html' %}
46+
</div>
47+
<div>
48+
{% include 'plotly_thisuserDailyMemoryRequested.html' %}
49+
</div>
50+
51+
<div>
52+
<h5>Failed jobs</h5>
53+
<p>
54+
Because any resource spent on a job is wasted if the job fails, it
55+
is important to test scripts and pipelines on small datasets.
56+
The chart below shows the daily success rate of {{ usersActivity[user.userID].n_jobs }}
57+
jobs that completed in the period.
58+
59+
Failed jobs represent {{ usersActivity[user.userID].carbonFootprint_failedJobs }} and
60+
a cost of {{ usersActivity[user.userID].cost_failedJobs }}.
61+
They are responsible for {{ usersActivity[user.userID].carbonFootprint_failedJobs_share }} of the overall
62+
carbon footprint.
63+
</p>
64+
{% include 'plotly_thisuserSuccessRate.html' %}
65+
<!-- TODO put text and pie chart side-by-side-->
66+
{% include 'plotly_thisuserDailySuccessRate.html' %}
67+
</div>
68+
69+
<div>
70+
<h5>Memory efficiency</h5>
71+
72+
<p>
73+
Memory can be a significant source of waste, because the power draw from memory mainly depends
74+
on the memory available, not on the actual memory used. The chart below shows the distribution
75+
of the memory efficiency collected from {{ usersActivity[user.userID].n_jobs }} jobs
76+
between {{ startDay }} and {{ endDay }} (the closer to 100% the better).
77+
</p>
78+
79+
{% include 'plotly_thisuserMemoryEfficiency.html' %}
80+
81+
<p>
82+
Using the memory efficiency, we can estimate how much memory was needed to run a job.
83+
If all jobs above had been submitted with only the memory they needed (rounded up),
84+
you would have emitted {{ usersActivity[user.userID].carbonFootprint_wasted_memoryOverallocation }} less
85+
and saved {{ usersActivity[user.userID].cost_wasted_memoryOverallocation }}.
86+
</p>
87+
</div>
88+
89+
90+
</div>
91+
</div>

‎frontend/templates/report_blank.html

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Green Algorithms dashboard</title>
6+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.3.0/css/all.min.css">
7+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@materializecss/materialize@1.1.0/dist/css/materialize.min.css">
8+
<link rel="stylesheet" href="styles.css">
9+
<!-- <link rel="icon" type="image/x-icon" href="https://www.ebi.ac.uk/favicon.ico">-->
10+
</head>
11+
<body>
12+
<section class="container">
13+
<header>
14+
<!-- TODO make the page responsive -->
15+
<nav id="top-nav">
16+
<i class="fa-solid fa-seedling"></i>
17+
<div id="title">
18+
<h1>Green Algorithms dashboard</h1>
19+
<h2>{{ institution }}</h2>
20+
</div>
21+
</nav>
22+
<p>
23+
Last updated: {{ last_updated }}
24+
</p>
25+
</header>
26+
27+
<div class="row">
28+
<div class="col l2">
29+
<div id="toc-wrapper">
30+
<ul class="section table-of-contents">
31+
<li><a href="#intro">Introduction</a></li>
32+
{% if include_user_context %}
33+
<li><a href="#user">User's data ({{ user.userID }})</a></li>
34+
{% endif %}
35+
<li><a href="#credits">Credits</a></li>
36+
<li><a href="#contact">Contact</a></li>
37+
<li><a href="#faq">FAQ</a></li>
38+
</ul>
39+
</div>
40+
</div>
41+
42+
<!-- starting column on the right -->
43+
<div class="col l10">
44+
<div id="intro" class="section scrollspy">
45+
<div class="card-panel warning">
46+
<p>
47+
This is an early version, please report any bug you find!
48+
</p>
49+
</div>
50+
51+
<p>
52+
Computing is a major contributor to energy consumption, and thus is one of the main sources of
53+
the carbon emission of our research.
54+
In the context of the global climate crisis, it is imperative that individuals and organizations
55+
find ways to assess then reduce the carbon footprint of their work.
56+
</p>
57+
58+
<p>
59+
This page aims to represent the carbon footprint that we are, collectively and individually,
60+
responsible for at {{ institution }}.
61+
SLURM jobs submitted to the {{ cluster_name }} High Performance Cluster are logged automatically
62+
(including information such as resource requested, run time, memory efficiency, etc.),
63+
and the corresponding carbon footprint was calculated using the framework proposed
64+
by <a href="https://green-algorithms.org/">Green Algorithms</a> and the following assumptions:
65+
</p>
66+
67+
<table>
68+
<tbody>
69+
<tr>
70+
<th>CPU</th>
71+
<td>{{ texts_intro.CPU }}</td>
72+
</tr>
73+
<tr>
74+
<th>GPU</th>
75+
<td>{{ texts_intro.GPU }}</td>
76+
</tr>
77+
<tr>
78+
<th>Memory power</th>
79+
<td>0.3725 W/GB</td>
80+
</tr>
81+
<tr>
82+
<th>Power usage effectiveness</th>
83+
<td>{{ PUE }}</td>
84+
</tr>
85+
<tr>
86+
<th>Carbon intensity</th>
87+
<td>{{ CI }} gCO<sub>2</sub>e/kWh</td>
88+
</tr>
89+
<tr>
90+
<th>Energy cost</th>
91+
<td>{{ energy_cost_perkWh.currency }}{{ energy_cost_perkWh.cost }}/kWh</td>
92+
</tr>
93+
</tbody>
94+
</table>
95+
96+
<div class="card-panel info">
97+
<p>
98+
We built this tool in the hope to raise awareness of computing usage,
99+
highlight resources waste, and foster good computing practices.
100+
This is intended to be a lightweight carbon footprint calculator, not a cluster monitoring system.
101+
</p>
102+
</div>
103+
</div>
104+
105+
{% if include_user_context %}
106+
{% include "_user.html" %}
107+
{% endif %}
108+
109+
<div id="credits" class="section scrollspy">
110+
<h4>Credits</h4>
111+
<p>
112+
This dashboard is the combination of a template developed at EMBL-EBI by Matthias Blum and Alex Bateman,
113+
and the Green Algorithms project led by Loïc Lannelongue and Michael Inouye.
114+
The carbon footprint calculations are described on <a href="https://www.green-algorithms.org/">the Green Algorithms project's website</a>.
115+
</p>
116+
</div>
117+
118+
<div id="contact" class="section scrollspy">
119+
<h4>Contact</h4>
120+
<p>
121+
If you want to report a bug or a user assigned to the wrong team,
122+
request a feature, or just give some general feedback, you can email LL582@medschl.cam.ac.uk.
123+
</p>
124+
</div>
125+
126+
<div id="faq" class="section scrollspy">
127+
<h4>FAQ</h4>
128+
<p>
129+
<span class="question">How is the information on SLRUM jobs collected?</span>
130+
Logs are pulled using the `sacct` command. It's all powered by the GA4HPC methods,
131+
you can check it out <a href="https://www.green-algorithms.org/GA4HPC/">there</a>.
132+
</p>
133+
134+
<p>
135+
<span class="question">Where can I ask more questions?</span>
136+
On the GitHub <a href="https://github.com/GreenAlgorithms/GreenAlgorithms4HPC/issues">here</a> or by email (see above)..
137+
</p>
138+
139+
140+
</div>
141+
142+
</div>
143+
144+
</div>
145+
146+
</section>
147+
</body>
148+
</html>

‎frontend/templates/styles.css

+339
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap');
2+
3+
@media only screen and (min-width: 993px) {
4+
.container {
5+
width: 85%;
6+
}
7+
}
8+
9+
@media only screen and (min-width: 1201px) {
10+
html {
11+
font-size:16px;
12+
}
13+
}
14+
15+
/* Override Materialize */
16+
html,
17+
button,
18+
input,
19+
optgroup,
20+
select,
21+
textarea {
22+
font-family: "IBM Plex Sans", sans-serif !important;
23+
}
24+
.btn, .tabs .tab { text-transform: inherit; }
25+
.btn.fluid { width: 100%; }
26+
td, th {
27+
padding: .5em .75em;
28+
}
29+
.section {
30+
padding: 2rem 0 0;
31+
}
32+
.section > h4 {
33+
margin-top: 0;
34+
}
35+
.input-field .right.circle {
36+
float: left !important;
37+
}
38+
img.circle {
39+
border: 1px solid #d4d4d5;
40+
}
41+
/*table#teams-table, table#users-table {*/
42+
/* font-size: .9rem;*/
43+
/*}*/
44+
.card-panel {
45+
padding: 1.25rem;
46+
}
47+
.card-panel > .card-title {
48+
font-size: 1.25rem;
49+
margin-bottom: .5rem;
50+
}
51+
.card-panel > .card-title + p {
52+
margin-top: 0;
53+
}
54+
.card-panel.info {
55+
background-color: #e1f5fe;
56+
border-left: 0.25rem solid #03a9f4;
57+
}
58+
.card-panel.warning {
59+
background-color: #fff8e1;
60+
border-left: 0.25rem solid #ffc107;
61+
}
62+
.card-panel.alert {
63+
background-color: #ffebee;
64+
border-left: 0.25rem solid #f44336;
65+
}
66+
.card-panel > :first-child {
67+
margin-top: 0;
68+
}
69+
.card-panel > :last-child {
70+
margin-bottom: 0;
71+
}
72+
.tabs .tab a {
73+
color: inherit;
74+
}
75+
.tabs .tab a:hover, .tabs .tab a.active {
76+
color: inherit;
77+
}
78+
.tabs .indicator {
79+
height: 4px;
80+
background-color: #18974c;
81+
}
82+
.tabs .tab a:focus, .tabs .tab a:focus.active {
83+
/*background-color: rgba(0,123,83,0.2);*/
84+
background-color: transparent;
85+
}
86+
input:not(.browser-default).invalid ~ .helper-text[data-error] > *,
87+
input:not(.browser-default).valid ~ .helper-text[data-success] > * {
88+
/* hide nested elements */
89+
display: none;
90+
}
91+
.modal-content > form:last-child {
92+
margin-bottom: 0;
93+
}
94+
table + h6 {
95+
margin-top: 2rem;
96+
}
97+
pre {
98+
background-color: #f6f8fa;
99+
border: 1px solid rgba(30, 30, 30, 0.1);
100+
border-radius: 3px;
101+
color: #24292F;
102+
font-size: .85em;
103+
padding: 8px 16px;
104+
}
105+
106+
#loader {
107+
position: fixed;
108+
left: 0;
109+
top: 0;
110+
width: 100%;
111+
height: 100%;
112+
background: #eee;
113+
padding: 5rem 0;
114+
color: #333;
115+
z-index: 99999;
116+
}
117+
input:not(.browser-default):focus:not([readonly]):not(.invalid) {
118+
border-bottom: 1px solid #3489ca !important;
119+
box-shadow: 0 1px 0 0 #3489ca !important;
120+
}
121+
input:not(.browser-default):focus:not([readonly]):not(.invalid) + label {
122+
color: #3489ca !important;
123+
}
124+
.custom.blue { background-color: #3489ca !important; }
125+
126+
[type="checkbox"].custom.blue.filled-in:checked + span:not(.lever)::after {
127+
border: 2px solid #3489ca !important;
128+
background-color: #3489ca !important;
129+
}
130+
131+
::placeholder {
132+
color: rgb(90, 95, 95);
133+
opacity: 0.5;
134+
}
135+
136+
/* Header */
137+
header {
138+
border-bottom: 1px solid rgba(0,0,0,.14);
139+
}
140+
#top-nav {
141+
background-color: inherit;
142+
box-shadow: inherit;
143+
color: #18974c;
144+
height: 150px;
145+
display: flex;
146+
flex-direction: row;
147+
align-items: center;
148+
margin-bottom: 20px;
149+
}
150+
#title {
151+
display: flex;
152+
flex-direction: column;
153+
}
154+
#title h1 {
155+
margin: 1rem 0 0;
156+
width: 100%;
157+
text-align: center;
158+
}
159+
#title h2 {
160+
margin: 1rem 0 0;
161+
width: 100%;
162+
text-align: center;
163+
font-size: 3rem;
164+
}
165+
#top-nav i {
166+
font-size: 4.2rem;
167+
width: 10%;
168+
margin: 1rem;
169+
}
170+
header p {
171+
color: rgba(0, 0, 0, .5);
172+
margin: 0 0 1rem;
173+
text-align: right;
174+
}
175+
176+
/* Intro/Abstract */
177+
#intro > p:first-child {
178+
font-weight: bold;
179+
font-size: 110%;
180+
}
181+
182+
/* Autocomplete highlight */
183+
.dropdown-content li > span {
184+
color: #444;
185+
}
186+
.autocomplete-content li .highlight {
187+
color: #26a69a;
188+
}
189+
190+
#remove-user {
191+
width: 100%;
192+
height: 48px;
193+
}
194+
195+
table + .pagination {
196+
margin-top: 1rem;
197+
text-align: center;
198+
}
199+
.pagination li {
200+
/* Override Materialize */
201+
vertical-align: auto;
202+
height: auto;
203+
margin: .25rem;
204+
}
205+
.pagination li.active {
206+
background-color: #485fc7;
207+
border-color: #485fc7;
208+
color: #fff;
209+
}
210+
.pagination li a {
211+
color: #363636;
212+
font-size: 1rem;
213+
height: auto;
214+
line-height: normal;
215+
min-width: 2.5em;
216+
padding: .5rem;
217+
user-select: none;
218+
}
219+
.pagination li:not(.ellipsis) {
220+
border: 1px solid #dbdbdb;
221+
border-radius: .375em;
222+
}
223+
224+
.pagination li.ellipsis {
225+
pointer-events: none;
226+
}
227+
228+
thead th.sortable {
229+
position: relative;
230+
padding-right: 20px;
231+
}
232+
thead th.sortable:hover {
233+
cursor: pointer;
234+
}
235+
thead th.sortable:hover::after {
236+
opacity: .25;
237+
}
238+
thead th.sortable::after {
239+
position: absolute;
240+
display: inline-block;
241+
opacity: .15;
242+
right: 10px;
243+
font-size: .8em;
244+
content: "▲";
245+
}
246+
247+
thead th.sortable.asc::after {
248+
content: "▲";
249+
opacity: 1;
250+
}
251+
252+
thead th.sortable.desc::after {
253+
content: "▼";
254+
opacity: 1;
255+
}
256+
.table-search {
257+
float: right;
258+
width: 350px;
259+
margin-bottom: 0;
260+
}
261+
.table-search > input[type="text"]:not(.browser-default) {
262+
border: 1px solid #9e9e9e;
263+
margin: 0;
264+
padding-left: 20px;
265+
}
266+
.table-search > input[type="text"]:focus:not(.browser-default) {
267+
border: 1px solid #3489ca !important;
268+
box-shadow: none !important;
269+
}
270+
271+
.table-of-contents a {
272+
border-color: #4caf50 !important;
273+
}
274+
.table-of-contents a.active {
275+
font-weight: bold;
276+
}
277+
.table-of-contents a ~ ul li {
278+
line-height: 1;
279+
/*padding: 0;*/
280+
}
281+
.table-of-contents a ~ ul li a {
282+
font-size: .85em;
283+
color: rgba(117, 117, 117, 0.75);
284+
line-height: 1;
285+
height: auto;
286+
}
287+
.table-of-contents a:not(.active) ~ ul {
288+
/*display: none;*/
289+
}
290+
291+
.highcharts-tooltip table > tbody > tr {
292+
border: none !important;
293+
}
294+
295+
.highcharts-tooltip table > tbody > tr > td {
296+
padding: .25em .5em;
297+
}
298+
#user-info {
299+
display: flex;
300+
align-items: center;
301+
margin-bottom: 1rem;
302+
/*height: 100px;*/
303+
}
304+
#user-info > img {
305+
height: 100px;
306+
width: 100px;
307+
}
308+
#user-info > .content {
309+
padding-left: 1.5rem;
310+
}
311+
#user-info > .content > h6 {
312+
/*display: inline-block;*/
313+
font-weight: 700;
314+
margin: 0;
315+
}
316+
#user-info > .content > .block {
317+
margin: .25em 0 .25em;
318+
color: rgba(0,0,0,.6);
319+
}
320+
#faq .question {
321+
font-weight: bold;
322+
display: block;
323+
}
324+
.stats-summary .col i {
325+
display: block;
326+
font-size: 3rem;
327+
margin-bottom: .5rem;
328+
}
329+
.stats-summary .col span:not([data-stat]) {
330+
font-size: 1.15rem;
331+
}
332+
.stats-summary .col span[data-stat] {
333+
display: block;
334+
font-size: 1.5rem;
335+
font-weight: 700;
336+
}
337+
#contact-email, #contact-slack {
338+
white-space: nowrap;
339+
}

‎frontend/terminal_output.py

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
2+
import math
3+
from frontend.helpers import formatText_footprint, formatText_treemonths, formatText_flying
4+
import pandas as pd
5+
import os
6+
7+
8+
def formatText_driving(dist):
9+
"""
10+
Format the text to display the driving distance
11+
:param dist: [float] driving distance, in km
12+
:return: [str] text to display
13+
"""
14+
if dist < 10:
15+
text_driving = f"driving {dist:,.2f} km"
16+
else:
17+
text_driving = f"driving {dist:,.0f} km"
18+
return text_driving
19+
20+
def generate_terminal_view(dict_stats_all, args, cluster_info):
21+
22+
user_here = dict_stats_all['user']
23+
dict_stats = dict_stats_all['userActivity'][user_here]
24+
text_nUsers = f"- user: {user_here} -"
25+
26+
## various variables
27+
clusterName = cluster_info['cluster_name']
28+
29+
## energy
30+
dcOverheads = dict_stats['energy'] - dict_stats['energy_CPUs'] - dict_stats['energy_GPUs'] - dict_stats['energy_memory']
31+
32+
## Carbon footprint
33+
text_footprint = formatText_footprint(dict_stats['carbonFootprint'])
34+
text_footprint_failedJobs = formatText_footprint(dict_stats['carbonFootprint_failedJobs'])
35+
text_footprint_wasted_memoryOverallocation = formatText_footprint(dict_stats['carbonFootprint']-dict_stats['carbonFootprint_memoryNeededOnly'])
36+
37+
## Context
38+
text_trees = formatText_treemonths(dict_stats['treeMonths'])
39+
text_trees_failedJobs = formatText_treemonths(dict_stats['treeMonths_failedJobs'])
40+
text_trees_wasted_memoryOverallocation = formatText_treemonths(dict_stats['treeMonths']-dict_stats['treeMonths_memoryNeededOnly'])
41+
text_driving = formatText_driving(dict_stats['driving'])
42+
text_flying = formatText_flying(dict_stats)
43+
44+
### Text filterCWD
45+
if args.filterWD is None:
46+
text_filterCWD = ''
47+
else:
48+
text_filterCWD = f"\n (NB: The only jobs considered here are those launched from {args.filterWD})\n"
49+
50+
### Text filterJobIDs
51+
if args.filterJobIDs == 'all':
52+
text_filterJobIDs = ''
53+
else:
54+
text_filterJobIDs = f"\n (NB: The only jobs considered here are those with job IDs: {args.filterJobIDs})\n"
55+
56+
### Text filter Account
57+
if args.filterAccount is None:
58+
text_filterAccount = ''
59+
else:
60+
text_filterAccount = f"\n (NB: The only jobs considered here are those charged under {args.filterAccount})\n"
61+
62+
### To get the title length right
63+
title_row1 = f"Carbon footprint on {clusterName}"
64+
title_row2 = text_nUsers
65+
title_row3 = f"({args.startDay} / {args.endDay})"
66+
max_length = max([len(title_row1), len(title_row2), len(title_row3)])
67+
68+
title_row1_full = f"# {' '*math.floor((max_length-len(title_row1))/2)}{title_row1}{' '*math.ceil((max_length-len(title_row1))/2)} #"
69+
title_row2_full = f"# {' '*math.floor((max_length-len(title_row2))/2)}{title_row2}{' '*math.ceil((max_length-len(title_row2))/2)} #"
70+
title_row3_full = f"# {' '*math.floor((max_length-len(title_row3))/2)}{title_row3}{' '*math.ceil((max_length-len(title_row3))/2)} #"
71+
72+
title = f'''
73+
{'#'*(max_length+6)}
74+
#{' '*(max_length+4)}#
75+
{title_row1_full}
76+
{title_row2_full}
77+
{title_row3_full}
78+
#{' '*(max_length+4)}#
79+
{'#'*(max_length+6)}
80+
'''
81+
82+
return f'''
83+
{title}
84+
85+
{'-' * (len(text_footprint) + 6)}
86+
| {text_footprint} |
87+
{'-' * (len(text_footprint) + 6)}
88+
89+
...This is equivalent to:
90+
- {text_trees}
91+
- {text_driving}
92+
- {text_flying}
93+
94+
...{dict_stats['failure_rate']:.1%} of the jobs failed, these represent a waste of {text_footprint_failedJobs} ({text_trees_failedJobs}).
95+
...On average, the jobs request at least {dict_stats['memoryOverallocationFactor']:,.1f} times the memory needed. By only requesting the memory needed, {text_footprint_wasted_memoryOverallocation} ({text_trees_wasted_memoryOverallocation}) could have been saved.
96+
{text_filterCWD}{text_filterJobIDs}{text_filterAccount}
97+
Energy used: {dict_stats['energy']:,.2f} kWh
98+
- CPUs: {dict_stats['energy_CPUs']:,.2f} kWh ({dict_stats['energy_CPUs'] / dict_stats['energy']:.2%})
99+
- GPUs: {dict_stats['energy_GPUs']:,.2f} kWh ({dict_stats['energy_GPUs'] / dict_stats['energy']:.2%})
100+
- Memory: {dict_stats['energy_memory']:,.2f} kWh ({dict_stats['energy_memory'] / dict_stats['energy']:.2%})
101+
- Data centre overheads: {dcOverheads:,.2f} kWh ({dcOverheads / dict_stats['energy']:.2%})
102+
Carbon intensity used for the calculations: {cluster_info['CI']:,} gCO2e/kWh
103+
104+
Summary of usage:
105+
- First/last job recorded on that period: {str(dict_stats['first_job_period'].date())}/{str(dict_stats['last_job_period'].date())}
106+
- Number of jobs: {dict_stats['n_jobs']:,} ({dict_stats['n_success']:,} completed)
107+
- Core hours used/charged: {dict_stats['CPUhoursCharged']:,.1f} (CPU), {dict_stats['GPUhoursCharged']:,.1f} (GPU), {dict_stats['CPUhoursCharged']+dict_stats['GPUhoursCharged']:,.1f} (total).
108+
- Total usage time (i.e. when cores were performing computations):
109+
- CPU: {str(dict_stats['cpuTime'])} ({dict_stats['cpuTime'].total_seconds()/3600:,.0f} hours)
110+
- GPU: {str(dict_stats['gpuTime'])} ({dict_stats['gpuTime'].total_seconds()/3600:,.0f} hours)
111+
- Total wallclock time: {str(dict_stats['wallclockTime'])}
112+
- Total memory requested: {dict_stats['memoryRequested']:,.0f} GB
113+
114+
Limitations to keep in mind:
115+
- The workload manager doesn't alway log the exact CPU usage time, and when this information is missing, we assume that all cores are used at 100%.
116+
- For now, we assume that for GPU jobs, the GPUs are used at 100% (as the information needed for more accurate measurement is not available)
117+
(this may lead to slightly overestimated carbon footprints, although the order of magnitude is likely to be correct)
118+
- Conversely, the wasted energy due to memory overallocation may be largely underestimated, as the information needed is not always logged.
119+
120+
Any bugs, questions, suggestions? Post on GitHub (GreenAlgorithms/GreenAlgorithms4HPC) or email LL582@medschl.cam.ac.uk
121+
{'-' * 80}
122+
Calculated using the Green Algorithms framework: www.green-algorithms.org
123+
Please cite https://onlinelibrary.wiley.com/doi/10.1002/advs.202100707
124+
'''
125+

‎myCarbonFootprint.sh

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#!/bin/bash
22

3+
# TODO update to new code. I think maybe best to have two script, one for the first install (creates virtual env, etc), and then a regular one
4+
35
## ~~~ TO BE EDITED TO BE TAILORED TO THE CLUSTER ~~~
46
##
57
## You only need to edit the module loading line (l.13), make sure you are loading python 3.7 or greater.
@@ -43,4 +45,4 @@ echo "Python versions: OK"
4345

4446
# Run the python code and pass on the arguments
4547
#userCWD="/home/ll582/ with space" # DEBUGONLY
46-
python GreenAlgorithms_global.py "$@" --userCWD "$userCWD"
48+
python __init__.py "$@" --userCWD "$userCWD"

‎requirements.txt

+5-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
numpy==1.20.1
2-
pandas==1.2.3
3-
python-dateutil==2.8.1
4-
pytz==2021.1
5-
PyYAML==5.4.1
6-
six==1.15.0
1+
numpy==1.24
2+
pandas==2.0
3+
PyYAML==6.0
4+
jinja2==3.1
5+
plotly==5.18

0 commit comments

Comments
 (0)
Please sign in to comment.