Repository: pytorch/ignite

Issue:
Insert logged times into state
## 🚀 Feature

By default, Engine measures times for single epoch and total run. These times are logged with `engine.logger`:
https://github.com/pytorch/ignite/blob/efc45a79ee0a5bf8105c3caff194758439db2426/ignite/engine/engine.py#L793-L796
and
https://github.com/pytorch/ignite/blob/efc45a79ee0a5bf8105c3caff194758439db2426/ignite/engine/engine.py#L497-L500

 Idea is to put these times (at least `time_taken` for epoch and total time) into `engine.state` under a name (e.g. "times"). Such that we can also use this information without need to use `Timer` or setup logger.


Discussion:
Very good idea!
I'm interested in working on this, @vfdev-5. What's your envisioned API? Something like `engine.state._complete_epoch()` and `engine.state._complete_train()`?
@erip thanks ! I was thinking about something like that:
```python

import time
from ignite.engine import Engine, Events

trainer = Engine(lambda e, b: time.sleep(0.01))

# INSTEAD OF MANUAL INIT, SETUP times INTO ENGINE.STATE
@trainer.on(Events.STARTED)
def init():
    trainer.state.times = {
        Events.EPOCH_COMPLETED.name: 0.0,
        Events.COMPLETED.name: 0.0
    }

# API :
@trainer.on(Events.EPOCH_COMPLETED | Events.COMPLETED)
def log_times():
    print(
        trainer.last_event_name.name,
        trainer.state.times[trainer.last_event_name.name]
    )
```

What do you think ?

**EDIT:** corrected wrong events.

That seems good - it would imply that a timer of some sort will need to be instantiated internal to `Engine.state`, right? I'm trying to think through how the Δt will be computed in the `log_times` call above.
@erip Actually, we above snippet is wrong. I fixed it.

> it would imply that a timer of some sort will need to be instantiated internal to Engine.state, right?

Yes, in `run` method or even in `State` by default...

> I'm trying to think through how the Δt will be computed in the log_times call above.

Maybe we do not need any deltas to compute as 
- `time_taken` in `_run_once_on_dataset` will give the time for 1 epoch.
- `time_taken` in `_internal_run` will give the time for complete run.

Does it sound good ?

One last question: do we want to maintain these times for _each_ epoch or only for the last completed epoch? i.e., do we want `trainer.state.times[Events.EPOCH_COMPLETED.name]` to be a list of times or just a time? I could see both being useful. I think it makes most since to just maintain a single value for now and let the user determine if/how they want to maintain that list if necessary.

Resolving patch:
diff --git a/examples/mnist/mnist.py b/examples/mnist/mnist.py
--- a/examples/mnist/mnist.py
+++ b/examples/mnist/mnist.py
@@ -99,6 +99,12 @@ def log_validation_results(engine):
 
         pbar.n = pbar.last_print_n = 0
 
+    @trainer.on(Events.EPOCH_COMPLETED | Events.COMPLETED)
+    def log_time(engine):
+        tqdm.write(
+            "{} took {} seconds".format(trainer.last_event_name.name, trainer.state.times[trainer.last_event_name.name])
+        )
+
     trainer.run(train_loader, max_epochs=epochs)
     pbar.close()
 
diff --git a/ignite/engine/engine.py b/ignite/engine/engine.py
--- a/ignite/engine/engine.py
+++ b/ignite/engine/engine.py
@@ -433,7 +433,7 @@ def terminate_epoch(self) -> None:
         )
         self.should_terminate_single_epoch = True
 
-    def _run_once_on_dataset(self) -> Tuple[int, int, int]:
+    def _run_once_on_dataset(self) -> float:
         start_time = time.time()
 
         # We need to setup iter_counter > 0 if we resume from an iteration
@@ -497,10 +497,7 @@ def _run_once_on_dataset(self) -> Tuple[int, int, int]:
             self.logger.error("Current run is terminating due to exception: %s.", str(e))
             self._handle_exception(e)
 
-        time_taken = time.time() - start_time
-        hours, mins, secs = _to_hours_mins_secs(time_taken)
-
-        return hours, mins, secs
+        return time.time() - start_time
 
     def _handle_exception(self, e: Exception) -> None:
         if Events.EXCEPTION_RAISED in self._event_handlers:
@@ -619,7 +616,8 @@ def run(
 
         Engine has a state and the following logic is applied in this function:
 
-        - At the first call, new state is defined by `max_epochs`, `epoch_length`, `seed` if provided.
+        - At the first call, new state is defined by `max_epochs`, `epoch_length`, `seed` if provided. A timer for
+            total and per-epoch time is initialized when Events.STARTED is handled.
         - If state is already defined such that there are iterations to run until `max_epochs` and no input arguments
             provided, state is kept and used in the function.
         - If state is defined and engine is "done" (no iterations to run until `max_epochs`), a new state is defined.
@@ -690,6 +688,11 @@ def switch_batch(engine):
         self.state.dataloader = data
         return self._internal_run()
 
+    @staticmethod
+    def _init_timers(state: State):
+        state.times[Events.EPOCH_COMPLETED.name] = 0.0
+        state.times[Events.COMPLETED.name] = 0.0
+
     def _setup_engine(self) -> None:
 
         try:
@@ -775,6 +778,7 @@ def setup_seed(self) -> None:
 
     def _internal_run(self) -> State:
         self.should_terminate = self.should_terminate_single_epoch = False
+        self._init_timers(self.state)
         try:
             start_time = time.time()
             self._fire_event(Events.STARTED)
@@ -785,16 +789,18 @@ def _internal_run(self) -> State:
                 if self._dataloader_iter is None:
                     self._setup_engine()
 
-                hours, mins, secs = self._run_once_on_dataset()
-
+                time_taken = self._run_once_on_dataset()
+                self.state.times[Events.EPOCH_COMPLETED.name] = time_taken
+                hours, mins, secs = _to_hours_mins_secs(time_taken)
                 self.logger.info("Epoch[%s] Complete. Time taken: %02d:%02d:%02d", self.state.epoch, hours, mins, secs)
                 if self.should_terminate:
                     break
                 self._fire_event(Events.EPOCH_COMPLETED)
 
-            self._fire_event(Events.COMPLETED)
             time_taken = time.time() - start_time
             hours, mins, secs = _to_hours_mins_secs(time_taken)
+            self.state.times[Events.COMPLETED.name] = time_taken
+            self._fire_event(Events.COMPLETED)
             self.logger.info("Engine run complete. Time taken %02d:%02d:%02d" % (hours, mins, secs))
 
         except BaseException as e:
diff --git a/ignite/engine/events.py b/ignite/engine/events.py
--- a/ignite/engine/events.py
+++ b/ignite/engine/events.py
@@ -246,6 +246,8 @@ class State:
         state.batch             # batch passed to `process_function`
         state.output            # output of `process_function` after a single iteration
         state.metrics           # dictionary with defined metrics if any
+        state.times             # dictionary with total and per-epoch times fetched on
+                                # keys: Events.EPOCH_COMPLETED.name and Events.COMPLETED.name
 
     """
 
@@ -270,6 +272,7 @@ def __init__(self, **kwargs):
         self.metrics = {}
         self.dataloader = None
         self.seed = None
+        self.times = {Events.EPOCH_COMPLETED.name: None, Events.COMPLETED.name: None}
 
         for k, v in kwargs.items():
             setattr(self, k, v)


Test patch:
diff --git a/tests/ignite/engine/test_engine.py b/tests/ignite/engine/test_engine.py
--- a/tests/ignite/engine/test_engine.py
+++ b/tests/ignite/engine/test_engine.py
@@ -1,4 +1,5 @@
 import os
+import time
 import pytest
 from unittest.mock import call, MagicMock, Mock
 
@@ -366,6 +367,27 @@ def test_state_get_event_attrib_value():
     assert state.get_event_attrib_value(e) == state.epoch
 
 
+def test_time_stored_in_state():
+    def _test(data, max_epochs, epoch_length):
+        sleep_time = 0.01
+        engine = Engine(lambda e, b: time.sleep(sleep_time))
+
+        def check_epoch_time(engine):
+            assert engine.state.times[Events.EPOCH_COMPLETED.name] >= sleep_time * epoch_length
+
+        def check_completed_time(engine):
+            assert engine.state.times[Events.COMPLETED.name] >= sleep_time * epoch_length * max_epochs
+
+        engine.add_event_handler(Events.EPOCH_COMPLETED, lambda e: check_epoch_time(e))
+        engine.add_event_handler(Events.COMPLETED, lambda e: check_completed_time(e))
+
+        engine.run(data, max_epochs=max_epochs, epoch_length=epoch_length)
+
+    _test(list(range(100)), max_epochs=2, epoch_length=100)
+    _test(list(range(200)), max_epochs=2, epoch_length=100)
+    _test(list(range(200)), max_epochs=5, epoch_length=100)
+
+
 def _test_run_check_triggered_events():
     def _test(data, max_epochs, epoch_length):
         engine = Engine(lambda e, b: 1)