diff --git a/tensorboard/components/tf_dashboard_common/data-loader-behavior.ts b/tensorboard/components/tf_dashboard_common/data-loader-behavior.ts index 2e0dbf2c75..fb04e7f6ec 100644 --- a/tensorboard/components/tf_dashboard_common/data-loader-behavior.ts +++ b/tensorboard/components/tf_dashboard_common/data-loader-behavior.ts @@ -81,13 +81,22 @@ namespace tf_dashboard_common { requestData: { type: Function, value: function() { - return (datum) => - this.requestManager.request(this.getDataLoadUrl(datum)); + return (datum) => { + const dataLoadUrl = this.getDataLoadUrl(datum); + let url; + let postData; + if (typeof dataLoadUrl === 'string') { + url = dataLoadUrl; + } else { + ({url, postData} = dataLoadUrl); + } + return this.requestManager.request(url, postData); + }; }, }, // A function that takes a datum and returns a string URL for fetching - // data. + // data. Optionally, returns an object like {url, postData}. getDataLoadUrl: Function, dataLoading: { diff --git a/tensorboard/plugins/scalar/http_api.md b/tensorboard/plugins/scalar/http_api.md index 394b57d7a1..8dc41d7294 100644 --- a/tensorboard/plugins/scalar/http_api.md +++ b/tensorboard/plugins/scalar/http_api.md @@ -61,3 +61,16 @@ instead be in CSV format: 1443856985.705543,1448,0.7461960315704346 1443857105.704628,3438,0.5427092909812927 1443857225.705133,5417,0.5457325577735901 + +## `/data/plugin/scalars/scalars_multirun` + +Accepts a POST request where the form data has a single field called `query`, +containing a JSON-encoded dict of this shape: + + { + "tag": "foo", + "runs": ["bar", "baz"], + } + +Returns a dict, keyed by run name, where each value is an array of the form +`[wall_time, step, value]` as above. \ No newline at end of file diff --git a/tensorboard/plugins/scalar/scalars_plugin.py b/tensorboard/plugins/scalar/scalars_plugin.py index fc1262d2e0..f86482ad29 100644 --- a/tensorboard/plugins/scalar/scalars_plugin.py +++ b/tensorboard/plugins/scalar/scalars_plugin.py @@ -24,6 +24,7 @@ import collections import csv +import json import six from six import StringIO @@ -69,6 +70,7 @@ def get_plugin_apps(self): return { "/scalars": self.scalars_route, "/tags": self.tags_route, + "/scalars_multirun": self.scalars_multirun_route, } def is_active(self): @@ -119,6 +121,25 @@ def scalars_impl(self, ctx, tag, run, experiment, output_format): else: return (values, "application/json") + def _scalars_multirun_impl(self, ctx, tag, runs, experiment): + """Result of the form `(body, mime_type)`.""" + all_scalars = self._data_provider.read_scalars( + ctx, + experiment_id=experiment, + plugin_name=metadata.PLUGIN_NAME, + downsample=self._downsample_to, + run_tag_filter=provider.RunTagFilter(runs=runs, tags=[tag]), + ) + result = {} + # Note we do not raise an error if data for a given run was not + # found; we just omit the run that case. + for run in all_scalars: + scalars = all_scalars[run][tag] + values = [(x.wall_time, x.step, x.value) for x in scalars] + result[run] = values + + return (result, "application/json") + @wrappers.Request.application def tags_route(self, request): ctx = plugin_util.context(request.environ) @@ -131,6 +152,7 @@ def scalars_route(self, request): """Given a tag and single run, return array of ScalarEvents.""" tag = request.args.get("tag") run = request.args.get("run") + ctx = plugin_util.context(request.environ) experiment = plugin_util.experiment_id(request.environ) output_format = request.args.get("format") @@ -138,3 +160,21 @@ def scalars_route(self, request): ctx, tag, run, experiment, output_format ) return http_util.Respond(request, body, mime_type) + + @wrappers.Request.application + def scalars_multirun_route(self, request): + """Given a tag and runs, return dict of run to array of ScalarEvents.""" + try: + query = json.loads(request.form["query"]) + except json.JSONDecodeError as e: + raise errors.InvalidArgumentError(e) + + tag = query["tag"] + runs = query["runs"] + + ctx = plugin_util.context(request.environ) + experiment = plugin_util.experiment_id(request.environ) + (body, mime_type) = self._scalars_multirun_impl( + ctx, tag, runs, experiment + ) + return http_util.Respond(request, body, mime_type) diff --git a/tensorboard/plugins/scalar/scalars_plugin_test.py b/tensorboard/plugins/scalar/scalars_plugin_test.py index 17713297f0..beda16f953 100644 --- a/tensorboard/plugins/scalar/scalars_plugin_test.py +++ b/tensorboard/plugins/scalar/scalars_plugin_test.py @@ -66,6 +66,7 @@ class ScalarsPluginTest(tf.test.TestCase): _RUN_WITH_LEGACY_SCALARS = "_RUN_WITH_LEGACY_SCALARS" _RUN_WITH_SCALARS = "_RUN_WITH_SCALARS" _RUN_WITH_HISTOGRAM = "_RUN_WITH_HISTOGRAM" + _RUN_WITH_NOTHING = "_RUN_WITH_NOTHING" def load_plugin(self, run_names): logdir = self.get_temp_dir() @@ -110,6 +111,8 @@ def generate_run(self, logdir, run_name): summ = tf.compat.v1.summary.histogram( self._HISTOGRAM_TAG, data ).numpy() + elif run_name == self._RUN_WITH_NOTHING: + continue else: assert False, "Invalid run name: %r" % run_name writer.add_summary(summ, global_step=step) @@ -171,6 +174,66 @@ def test_scalars_with_scalars(self): self.assertEqual("application/json", response.headers["Content-Type"]) self.assertEqual(self._STEPS, len(json.loads(response.get_data()))) + def test_scalars_multirun_with_scalars(self): + server = self.load_server( + [self._RUN_WITH_SCALARS, self._RUN_WITH_HISTOGRAM] + ) + response = server.post( + "/data/plugin/scalars/scalars_multirun", + data={ + "query": json.dumps( + { + "tag": "%s/scalar_summary" % self._SCALAR_TAG, + "runs": [ + self._RUN_WITH_SCALARS, + self._RUN_WITH_HISTOGRAM, + ], + } + ) + }, + ) + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response.headers["Content-Type"]) + r = json.loads(response.get_data()) + self.assertItemsEqual([self._RUN_WITH_SCALARS], r.keys()) + self.assertEqual(self._STEPS, len(r[self._RUN_WITH_SCALARS])) + + def test_scalars_multirun_with_no_scalars(self): + server = self.load_server([self._RUN_WITH_HISTOGRAM]) + response = server.post( + "/data/plugin/scalars/scalars_multirun", + data={ + "query": json.dumps( + { + "tag": "%s/scalar_summary" % self._SCALAR_TAG, + "runs": [self._RUN_WITH_HISTOGRAM], + } + ) + }, + ) + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response.headers["Content-Type"]) + r = json.loads(response.get_data()) + self.assertEmpty(r) + + def test_scalars_multirun_with_empty_run(self): + server = self.load_server([self._RUN_WITH_NOTHING]) + response = server.post( + "/data/plugin/scalars/scalars_multirun", + data={ + "query": json.dumps( + { + "tag": "%s/scalar_summary" % self._SCALAR_TAG, + "runs": [self._RUN_WITH_NOTHING], + } + ) + }, + ) + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response.headers["Content-Type"]) + r = json.loads(response.get_data()) + self.assertEmpty(r) + def test_scalars_with_histogram(self): server = self.load_server([self._RUN_WITH_HISTOGRAM]) response = server.get( diff --git a/tensorboard/plugins/scalar/tf_scalar_dashboard/tf-scalar-card.html b/tensorboard/plugins/scalar/tf_scalar_dashboard/tf-scalar-card.html index 5e08ddd6e5..e129a66e4d 100644 --- a/tensorboard/plugins/scalar/tf_scalar_dashboard/tf-scalar-card.html +++ b/tensorboard/plugins/scalar/tf_scalar_dashboard/tf-scalar-card.html @@ -45,8 +45,8 @@