This commit is contained in:
liuzhe-lz 2023-02-08 07:20:28 +08:00 коммит произвёл GitHub
Родитель 48f2df5706
Коммит 9e1a8e8ff7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
19 изменённых файлов: 581 добавлений и 40 удалений

Просмотреть файл

@ -1,6 +1,8 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
from __future__ import annotations
from typing import Any
Command = Any # TODO

Просмотреть файл

@ -1,6 +1,8 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
from __future__ import annotations
import logging
import requests

Просмотреть файл

@ -11,9 +11,10 @@ _trial_env_var_names = [
'NNI_TRIAL_JOB_ID',
'NNI_SYS_DIR',
'NNI_OUTPUT_DIR',
'NNI_TRIAL_COMMAND_CHANNEL',
'NNI_TRIAL_SEQ_ID',
'MULTI_PHASE',
'REUSE_MODE'
'REUSE_MODE',
]
_dispatcher_env_var_names = [

Просмотреть файл

@ -43,7 +43,11 @@ def set_default_trial_command_channel(channel: Optional[TrialCommandChannel] = N
assert dispatcher_env_vars.SDK_PROCESS != 'dispatcher'
if trial_env_vars.NNI_PLATFORM is None or trial_env_vars.NNI_PLATFORM == 'unittest':
channel_url = trial_env_vars.NNI_TRIAL_COMMAND_CHANNEL
if channel_url:
from .v3 import TrialCommandChannelV3
_channel = TrialCommandChannelV3(channel_url)
elif trial_env_vars.NNI_PLATFORM is None or trial_env_vars.NNI_PLATFORM == 'unittest':
from .standalone import StandaloneTrialCommandChannel
_channel = StandaloneTrialCommandChannel()
else:

Просмотреть файл

@ -0,0 +1,51 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
from __future__ import annotations
import logging
from typing_extensions import Literal
import nni
from nni.runtime.command_channel.http import HttpChannel
from nni.typehint import ParameterRecord, TrialMetric
from .base import TrialCommandChannel
_logger = logging.getLogger(__name__)
class TrialCommandChannelV3(TrialCommandChannel):
def __init__(self, url: str):
assert url.startswith('http://'), 'Only support HTTP command channel' # TODO
_logger.info(f'Connect to trial command channel {url}')
self._channel: HttpChannel = HttpChannel(url)
def receive_parameter(self) -> ParameterRecord | None:
req = {'type': 'request_parameter'}
self._channel.send(req)
res = self._channel.receive()
if res is None:
_logger.error('Trial command channel is closed')
return None
assert res['type'] == 'parameter'
return nni.load(res['parameter'])
def send_metric(self,
type: Literal['PERIODICAL', 'FINAL'], # pylint: disable=redefined-builtin
parameter_id: int | None,
trial_job_id: str,
sequence: int,
value: TrialMetric) -> None:
metric = {
'parameter_id': parameter_id,
'trial_job_id': trial_job_id,
'type': type,
'sequence': sequence,
'value': nni.dump(value),
}
command = {
'type': 'metric',
'metric': nni.dump(metric),
}
self._channel.send(command)

Просмотреть файл

@ -174,7 +174,7 @@ stages:
- job: ubuntu_legacy
pool:
vmImage: ubuntu-18.04
vmImage: ubuntu-20.04
steps:
- template: templates/install-dependencies.yml

Просмотреть файл

@ -194,6 +194,7 @@ def launch_test(config_file, training_service, test_case_config):
bg_time = time.time()
print(str(datetime.datetime.now()), ' waiting ...', flush=True)
experiment_id = '_latest'
try:
# wait restful server to be ready
time.sleep(3)

Просмотреть файл

@ -159,7 +159,7 @@ def deep_update(source, overrides):
Modify ``source`` in place.
"""
for key, value in overrides.items():
if isinstance(value, collections.Mapping) and value:
if isinstance(value, collections.abc.Mapping) and value:
returned = deep_update(source.get(key, {}), value)
source[key] = returned
else:

Просмотреть файл

@ -1,4 +1,5 @@
import os
from pathlib import Path
import sys
import nni
@ -31,7 +32,7 @@ def ensure_success(exp: RetiariiExperiment):
exp.config.canonical_copy().experiment_working_directory,
exp.id
)
assert os.path.exists(exp_dir) and os.path.exists(os.path.join(exp_dir, 'trials'))
assert os.path.exists(exp_dir)
# check job status
job_stats = exp.get_job_statistics()
@ -39,7 +40,9 @@ def ensure_success(exp: RetiariiExperiment):
print('Experiment jobs did not all succeed. Status is:', job_stats, file=sys.stderr)
print('Trying to fetch trial logs.', file=sys.stderr)
for root, _, files in os.walk(os.path.join(exp_dir, 'trials')):
# FIXME: this is local only; waiting log collection
trials_dir = Path(exp_dir) / 'environments/local-env/trials'
for root, _, files in os.walk(trials_dir):
for file in files:
fpath = os.path.join(root, file)
print('=' * 10 + ' ' + fpath + ' ' + '=' * 10, file=sys.stderr)
@ -99,19 +102,20 @@ def get_mnist_evaluator():
)
def test_multitrial_experiment(pytestconfig):
base_model = Net()
evaluator = get_mnist_evaluator()
search_strategy = strategy.Random()
exp = RetiariiExperiment(base_model, evaluator, strategy=search_strategy)
exp_config = RetiariiExeConfig('local')
exp_config.trial_concurrency = 1
exp_config.max_trial_number = 1
exp_config._trial_command_params = nas_experiment_trial_params(pytestconfig.rootpath)
exp.run(exp_config)
ensure_success(exp)
assert isinstance(exp.export_top_models()[0], dict)
exp.stop()
# FIXME: temporarily disabled for training service refactor
#def test_multitrial_experiment(pytestconfig):
# base_model = Net()
# evaluator = get_mnist_evaluator()
# search_strategy = strategy.Random()
# exp = RetiariiExperiment(base_model, evaluator, strategy=search_strategy)
# exp_config = RetiariiExeConfig('local')
# exp_config.trial_concurrency = 1
# exp_config.max_trial_number = 1
# exp_config._trial_command_params = nas_experiment_trial_params(pytestconfig.rootpath)
# exp.run(exp_config)
# ensure_success(exp)
# assert isinstance(exp.export_top_models()[0], dict)
# exp.stop()
def test_oneshot_experiment():

Просмотреть файл

@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
export class DefaultMap<K, V> extends Map<K, V> {
private defaultFactory: () => V;
constructor(defaultFactory: () => V) {
super();
this.defaultFactory = defaultFactory;
}
public get(key: K): V {
const value = super.get(key);
if (value !== undefined) {
return value;
}
const defaultValue = this.defaultFactory();
this.set(key, defaultValue);
return defaultValue;
}
}

Просмотреть файл

@ -62,7 +62,7 @@ export interface TrainingServiceV3 {
* Return trial ID on success.
* Return null if the environment is not available.
**/
createTrial(environmentId: string, trialCommand: string, directoryName: string): Promise<string | null>;
createTrial(environmentId: string, trialCommand: string, directoryName: string, sequenceId?: number): Promise<string | null>;
/**
* Kill a trial.

Просмотреть файл

@ -38,7 +38,7 @@ export async function collectGpuInfo(forceUpdate?: boolean): Promise<GpuSystemIn
let str: string;
try {
const args = (forceUpdate ? [ '--detail' ] : undefined);
str = await runPythonModule('nni.tools.training_service_scripts.collect_gpu_info', args);
str = await runPythonModule('nni.tools.nni_manager_scripts.collect_gpu_info', args);
} catch (error) {
logger.error('Failed to collect GPU info:', error);
return null;

Просмотреть файл

@ -58,7 +58,7 @@ export class TaskSchedulerClient {
public async release(trialId: string): Promise<void> {
if (this.server !== null) {
await this.release(trialId);
await this.server.release(globals.args.experimentId, trialId);
}
}
}

Просмотреть файл

@ -479,8 +479,8 @@ class NNIManager implements Manager {
const module_ = await import('../training_service/reusable/routerTrainingService');
return await module_.RouterTrainingService.construct(config);
} else if (platform === 'local') {
const module_ = await import('../training_service/local/localTrainingService');
return new module_.LocalTrainingService(<LocalConfig>config.trainingService);
const module_ = await import('../training_service/v3/compat');
return new module_.V3asV1(config.trainingService as TrainingServiceConfig);
} else if (platform === 'kubeflow') {
const module_ = await import('../training_service/kubernetes/kubeflow/kubeflowTrainingService');
return new module_.KubeflowTrainingService();

Просмотреть файл

@ -0,0 +1,301 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import assert from 'assert/strict';
import fs from 'fs/promises';
import { Server } from 'http';
import os from 'os';
import path from 'path';
import { setTimeout } from 'timers/promises';
import express from 'express';
import { DefaultMap } from 'common/default_map';
import { Deferred } from 'common/deferred';
import type { LocalConfig } from 'common/experimentConfig';
import globals from 'common/globals/unittest';
import { LocalTrainingServiceV3 } from 'training_service/local_v3';
/**
* This is in fact an integration test.
*
* It tests following tasks:
*
* 1. Create two trials concurrently.
* 2. Create a trial that will crash.
* 3. Create a trial and kill it.
*
* As an integration test, the environment is a bit complex.
* It requires a temporary directory to generate trial codes,
* and it requires an express server to serve trials' command channel.
*
* The trials' output (including stderr) can be found in "nni-experiments/unittest".
* This is configured by "common/globals/unittest".
**/
describe('## training_service.local_v3 ##', () => {
before(beforeHook);
it('start', testStart);
it('concurrent trials', testConcurrentTrials);
it('failed trial', testFailedTrial);
it('stop trial', testStopTrial);
it('stop', testStop);
after(afterHook);
});
/* global states */
const config: LocalConfig = {
platform: 'local',
trialCommand: 'not used',
trialCodeDirectory: 'not used',
maxTrialNumberPerGpu: 1,
reuseMode: true,
};
// The training service.
const ts = new LocalTrainingServiceV3('test-local', config);
// Event recorders.
// The key is trial ID, and the value is resovled when the corresponding callback has been invoked.
const trialStarted: DefaultMap<string, Deferred<void>> = new DefaultMap(() => new Deferred());
const trialStopped: DefaultMap<string, Deferred<void>> = new DefaultMap(() => new Deferred());
const paramSent: DefaultMap<string, Deferred<void>> = new DefaultMap(() => new Deferred());
// Each trial's exit code.
// When the default shell is powershell, all non-zero values may become 1.
const exitCodes: Record<string, number | null> = {};
// Trial parameters to be sent.
// Each trial consumes one in order.
const parameters = [ { x: 1 }, { x: 2 }, { x: 3 }, { x: 4 } ];
// Received trial metrics.
const metrics: any[] = [];
let envId: string;
/* test cases */
async function testStart() {
await ts.init();
ts.onTrialStart(async (trialId, _time) => {
trialStarted.get(trialId).resolve();
});
ts.onTrialEnd(async (trialId, _time, code) => {
trialStopped.get(trialId).resolve();
exitCodes[trialId] = code;
});
ts.onRequestParameter(async (trialId) => {
ts.sendParameter(trialId, formatParameter(parameters.shift()));
paramSent.get(trialId).resolve();
});
ts.onMetric(async (trialId, metric) => {
metrics.push({ trialId, metric: JSON.parse(metric) });
});
const envs = await ts.start();
assert.equal(envs.length, 1);
envId = envs[0].id;
}
/**
* Run two trials concurrently.
**/
async function testConcurrentTrials() {
const origParamLen = parameters.length;
metrics.length = 0;
const trialCode = `
import nni
param = nni.get_next_parameter()
nni.report_intermediate_result(param['x'] * 0.5)
nni.report_intermediate_result(param['x'])
nni.report_final_result(param['x'])
`;
const dir1 = await writeTrialCode('dir1', 'trial.py', trialCode);
const dir2 = await writeTrialCode('dir2', 'trial.py', trialCode);
await ts.uploadDirectory('dir1', dir1);
await ts.uploadDirectory('dir2', dir2);
const [trial1, trial2] = await Promise.all([
ts.createTrial(envId, 'python trial.py', 'dir1'),
ts.createTrial(envId, 'python trial.py', 'dir2'),
]);
// the creation should success
assert.notEqual(trial1, null);
assert.notEqual(trial2, null);
// start and stop callbacks should be invoked
await trialStopped.get(trial1!).promise;
assert.ok(trialStarted.get(trial1!).settled);
await trialStopped.get(trial2!).promise;
assert.ok(trialStarted.get(trial2!).settled);
// exit code should be 0
assert.equal(exitCodes[trial1!], 0, 'trial #1 exit code should be 0');
assert.equal(exitCodes[trial2!], 0, 'trial #2 exit code should be 0');
// each trial should consume 1 parameter and yield 3 metrics
assert.equal(parameters.length, origParamLen - 2);
assert.equal(metrics.length, 6);
// verify metric value
// because the two trials are created concurrently,
// we don't know who gets the first parameter and who gets the second
const metrics1 = getMetrics(trial1!);
const metrics2 = getMetrics(trial2!);
if (metrics1[0] === 1) {
assert.deepEqual(metrics1, [ 1, 2, 2 ]);
assert.deepEqual(metrics2, [ 0.5, 1, 1 ]);
} else {
assert.deepEqual(metrics2, [ 1, 2, 2 ]);
assert.deepEqual(metrics1, [ 0.5, 1, 1 ]);
}
}
/**
* Run a trial that exits with code 1.
**/
async function testFailedTrial() {
const origParamLen = parameters.length;
metrics.length = 0;
const trialCode = `exit(1)`;
const dir = await writeTrialCode('dir1', 'trial_fail.py', trialCode);
await ts.uploadDirectory('code_dir', dir);
const trial: string = (await ts.createTrial(envId, 'python trial_fail.py', 'code_dir'))!;
// despite it exit immediately, the creation should be success
assert.notEqual(trial, null);
// the callbacks should be invoked
await trialStopped.get(trial).promise;
assert.ok(trialStarted.get(trial).settled);
// exit code should be 1
assert.equal(exitCodes[trial], 1);
// it should not consume parameter or yield metrics
assert.equal(parameters.length, origParamLen);
assert.equal(metrics.length, 0);
}
/**
* Create a long running trial and stop it.
**/
async function testStopTrial() {
const origParamLen = parameters.length;
metrics.length = 0;
const trialCode = `
import sys
import time
import nni
param = nni.get_next_parameter()
nni.report_intermediate_result(sys.version_info.minor) # python 3.7 behaves differently
time.sleep(60)
nni.report_intermediate_result(param['x'])
nni.report_final_result(param['x'])
`
const dir = await writeTrialCode('dir1', 'trial_long.py', trialCode);
await ts.uploadDirectory('code_dir', dir);
const trial: string = (await ts.createTrial(envId, 'python trial_long.py', 'dir1'))!;
assert.notEqual(trial, null);
// wait for it to request parameter
await paramSent.get(trial).promise;
// wait a while for it to report first intermediate result
await setTimeout(100); // TODO: use an env var to distinguish pipeline so we can reduce the delay
await ts.stopTrial(trial);
// the callbacks should be invoked
await setTimeout(1);
assert.ok(trialStopped.get(trial).settled);
assert.ok(trialStarted.get(trial).settled);
// it should consume 1 parameter and yields one metric
assert.equal(parameters.length, origParamLen - 1);
assert.equal(getMetrics(trial).length, 1);
// killed trials' exit code should be null for python 3.8+
// in 3.7 there is a bug (bpo-1054041)
if (getMetrics(trial)[0] !== 7) {
assert.equal(exitCodes[trial], null);
}
}
async function testStop() {
await ts.stop();
}
/* environment */
let tmpDir: string | null = null;
let server: Server | null = null;
async function beforeHook(): Promise<void> {
/* create tmp dir */
const tmpRoot = path.join(os.tmpdir(), 'nni-ut');
await fs.mkdir(tmpRoot, { recursive: true });
tmpDir = await fs.mkdtemp(tmpRoot + path.sep);
/* launch rest server */
const app = express();
app.use('/', globals.rest.getExpressRouter());
server = app.listen(0);
const deferred = new Deferred<void>();
server.on('listening', () => {
globals.args.port = (server!.address() as any).port;
deferred.resolve();
});
await deferred.promise;
}
async function afterHook() {
if (tmpDir !== null) {
await fs.rm(tmpDir, { force: true, recursive: true });
}
if (server !== null) {
const deferred = new Deferred<void>();
server.close(() => { deferred.resolve(); });
await deferred.promise;
}
globals.reset();
}
/* helpers */
async function writeTrialCode(dir: string, file: string, content: string): Promise<string> {
await fs.mkdir(path.join(tmpDir!, dir), { recursive: true });
await fs.writeFile(path.join(tmpDir!, dir, file), content);
return path.join(tmpDir!, dir);
}
// FIXME: parameter / metric formatting should be more structural so it does not need helpers here
function formatParameter(param: any) {
return JSON.stringify({
parameter_id: param.x,
parameters: param,
});
}
function getMetrics(trialId: string): number[] {
return metrics.filter(metric => (metric.trialId === trialId)).map(metric => JSON.parse(metric.metric.value)) as any;
}

Просмотреть файл

@ -0,0 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
export { LocalTrainingServiceV3 } from './local';

Просмотреть файл

@ -0,0 +1,123 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { Logger, getLogger } from 'common/log';
import type { LocalConfig, TrainingServiceConfig } from 'common/experimentConfig';
import type { EnvironmentInfo, Metric, Parameter, TrainingServiceV3 } from 'common/training_service_v3';
import { TrialKeeper } from 'common/trial_keeper/keeper';
export class LocalTrainingServiceV3 implements TrainingServiceV3 {
private config: LocalConfig;
private env: EnvironmentInfo;
private log: Logger;
private trialKeeper: TrialKeeper;
constructor(trainingServiceId: string, config: TrainingServiceConfig) {
this.log = getLogger(`LocalV3.${trainingServiceId}`);
this.log.debug('Training sevice config:', config);
this.config = config as LocalConfig;
this.env = { id: `${trainingServiceId}-env` };
this.trialKeeper = new TrialKeeper(this.env.id, 'local', Boolean(config.trialGpuNumber));
}
public async init(): Promise<void> {
return;
}
public async start(): Promise<EnvironmentInfo[]> {
this.log.info('Start');
await this.trialKeeper.start();
return [ this.env ];
}
public async stop(): Promise<void> {
await this.trialKeeper.shutdown();
this.log.info('All trials stopped');
}
/**
* Note:
* The directory is not copied, so changes in code directory will affect new trials.
* This is different from all other training services.
**/
public async uploadDirectory(directoryName: string, path: string): Promise<void> {
this.log.info(`Register directory ${directoryName} = ${path}`);
this.trialKeeper.registerDirectory(directoryName, path);
}
public async createTrial(_envId: string, trialCommand: string, directoryName: string, sequenceId?: number):
Promise<string | null> {
const trialId = uuid();
let gpuNumber = this.config.trialGpuNumber;
if (gpuNumber) {
gpuNumber /= this.config.maxTrialNumberPerGpu;
}
const opts: TrialKeeper.TrialOptions = {
id: trialId,
command: trialCommand,
codeDirectoryName: directoryName,
sequenceId,
gpuNumber,
gpuRestrictions: {
onlyUseIndices: this.config.gpuIndices,
rejectActive: !this.config.useActiveGpu,
},
};
const success = await this.trialKeeper.createTrial(opts);
if (success) {
this.log.info('Created trial', trialId);
return trialId;
} else {
this.log.warning('Failed to create trial');
return null;
}
}
public async stopTrial(trialId: string): Promise<void> {
this.log.info('Stop trial', trialId);
await this.trialKeeper.stopTrial(trialId);
}
public async sendParameter(trialId: string, parameter: Parameter): Promise<void> {
this.log.info('Trial parameter:', trialId, parameter);
const command = { type: 'parameter', parameter };
await this.trialKeeper.sendCommand(trialId, command);
}
public onTrialStart(callback: (trialId: string, timestamp: number) => Promise<void>): void {
this.trialKeeper.onTrialStart(callback);
}
public onTrialEnd(callback: (trialId: string, timestamp: number, exitCode: number | null) => Promise<void>): void {
this.trialKeeper.onTrialStop(callback);
}
public onRequestParameter(callback: (trialId: string) => Promise<void>): void {
this.trialKeeper.onReceiveCommand('request_parameter', (trialId, _command) => {
callback(trialId);
});
}
public onMetric(callback: (trialId: string, metric: Metric) => Promise<void>): void {
this.trialKeeper.onReceiveCommand('metric', (trialId, command) => {
callback(trialId, (command as any)['metric']);
});
}
public onEnvironmentUpdate(_callback: (environments: EnvironmentInfo[]) => Promise<void>): void {
// never
}
}
// Temporary helpers, will be moved later
import { uniqueString } from 'common/utils';
function uuid(): string {
return uniqueString(5);
}

Просмотреть файл

@ -18,6 +18,20 @@ type MutableTrialJobDetail = {
-readonly [Property in keyof TrialJobDetail]: TrialJobDetail[Property];
};
const placeholderDetail: TrialJobDetail = {
id: '',
status: 'UNKNOWN',
submitTime: 0,
workingDirectory: '_unset_',
form: {
sequenceId: -1,
hyperParameters: {
value: 'null',
index: -1,
}
}
};
export class V3asV1 implements TrainingService {
private config: TrainingServiceConfig;
private v3: TrainingServiceV3;
@ -56,21 +70,29 @@ export class V3asV1 implements TrainingService {
public async submitTrialJob(form: TrialJobApplicationForm): Promise<TrialJobDetail> {
await this.startDeferred.promise;
let trialId: string | null = null;
let submitTime: number = 0;
while (trialId === null) {
const envId = this.schedule();
trialId = await this.v3.createTrial(envId, this.config.trialCommand, 'trial_code');
submitTime = Date.now();
trialId = await this.v3.createTrial(envId, this.config.trialCommand, 'trial_code', form.sequenceId);
}
// In new interface, hyper parameters will be sent on demand.
this.parameters[trialId] = form.hyperParameters.value;
this.trialJobs[trialId] = {
id: trialId,
status: 'WAITING',
submitTime: Date.now(),
workingDirectory: '_unset_', // never set in current remote training service, so it's optional
form: form,
};
if (this.trialJobs[trialId] === undefined) {
this.trialJobs[trialId] = {
id: trialId,
status: 'WAITING',
submitTime,
workingDirectory: '_unset_', // never set in current remote training service, so it's optional
form: form,
};
} else {
// `await createTrial()` is not atomic, so onTrialStart callback might be invoked before this
this.trialJobs[trialId].submitTime = submitTime;
this.trialJobs[trialId].form = form;
}
return this.trialJobs[trialId];
}
@ -142,6 +164,10 @@ export class V3asV1 implements TrainingService {
this.emitter.emit('metric', { id: trialId, data: metric });
});
this.v3.onTrialStart(async (trialId, timestamp) => {
if (this.trialJobs[trialId] === undefined) {
this.trialJobs[trialId] = structuredClone(placeholderDetail);
this.trialJobs[trialId].id = trialId;
}
this.trialJobs[trialId].status = 'RUNNING';
this.trialJobs[trialId].startTime = timestamp;
});

Просмотреть файл

@ -9,15 +9,15 @@
import type { LocalConfig, RemoteConfig, TrainingServiceConfig } from 'common/experimentConfig';
import type { TrainingServiceV3 } from 'common/training_service_v3';
//import { LocalTrainingServiceV3 } from './local';
import { LocalTrainingServiceV3 } from '../local_v3';
//import { RemoteTrainingServiceV3 } from './remote';
export function trainingServiceFactoryV3(config: TrainingServiceConfig): TrainingServiceV3 {
//if (config.platform === 'local_v3') {
// return new LocalTrainingServiceV3(config);
//} else if (config.platform === 'remote_v3') {
// return new RemoteTrainingServiceV3(config);
//} else {
throw new Error(`Bad training service platform: ${config.platform}`);
//}
if (config.platform.startsWith('local')) {
return new LocalTrainingServiceV3('local', config);
//} else if (config.platform.startsWith('remote')) {
// return new RemoteTrainingServiceV3('remote', config);
} else {
throw new Error(`Bad training service platform: ${config.platform}`);
}
}