Repository: ipython/ipython

Issue:
Printing many lines of output is ~50x slower in ipython v9.1.0 and above compared to v9.0.2 and below
I noticed this because when I ran `history -g` to print my full ipython history from the beginning of time, it completed in about half a second when I was in ipython version 9.0.2 or below. Starting with v9.1.0 and above, `history -g` takes an astronomical _30 seconds_ to go through the process of printing these many thousands of line to the terminal.

The problem isn't specific to the `history` command, because just printing a ton of numbers to the terminal shows the same pattern – For me, the following takes about 1 second on ipython v9.0.2 but over a minute on ipython v9.1.0:
```python
for i in range(1_000_000):
    print(i)
```

I'm on an M1 Macbook, and have gotten this same behavior in both python 3.11 and 3.12. (For lower versions of python, there doesn't seem to be a wheel available to install ipython version 9.x.x, so those versions of python stay with ipython version 8.x.x)

Can anyone else reproduce this?


Discussion:
Just to be clear, it's a printing/displaying problem, not a looping problem – this completes essentially instantly:
```python
for i in range(1_000_000):
    pass
```
This could be overhead from output storage added in https://github.com/ipython/ipython/pull/14780 for `%notebook` magic. If confirmed, we could:
- try to optimize the `_tee` context manager
- add a toggle to disable it
- maybe default the toggle to off if that is a more of a nuisance than the benefit from having a fully functional `%notebook` magic for most of the users
Looking at the code, it appears that `output_stream.bundle["stream"] += data` below might be at fault. Instead of concatenating large strings at runtime we should ideally store a list of chunks and concatenate them on access:

https://github.com/ipython/ipython/blob/cc614bdc1d3f61303e265c26d593d384e4a90d1e/IPython/core/interactiveshell.py#L3057-L3071

We should also have a test for performance of stream operation.

CC @Darshan808 as author of https://github.com/ipython/ipython/pull/14780



Patch:
diff --git a/IPython/core/history.py b/IPython/core/history.py
index abb434407c9..929b472bd44 100644
--- a/IPython/core/history.py
+++ b/IPython/core/history.py
@@ -590,7 +590,7 @@ class HistoryOutput:
     output_type: typing.Literal[
         "out_stream", "err_stream", "display_data", "execute_result"
     ]
-    bundle: typing.Dict[str, str]
+    bundle: typing.Dict[str, str | list[str]]
 
 
 class HistoryManager(HistoryAccessor):
diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py
index ec85e2d3ad9..1176537079d 100644
--- a/IPython/core/interactiveshell.py
+++ b/IPython/core/interactiveshell.py
@@ -3063,11 +3063,11 @@ def write(data, *args, **kwargs):
                     output_stream = outputs[-1]
             if output_stream is None:
                 output_stream = HistoryOutput(
-                    output_type=output_type, bundle={"stream": ""}
+                    output_type=output_type, bundle={"stream": []}
                 )
                 outputs_by_counter[execution_count].append(output_stream)
 
-            output_stream.bundle["stream"] += data  # Append to existing stream
+            output_stream.bundle["stream"].append(data)  # Append to existing stream
             return result
 
         stream.write = write
diff --git a/IPython/core/magics/basic.py b/IPython/core/magics/basic.py
index ec1ab532337..386707d236e 100644
--- a/IPython/core/magics/basic.py
+++ b/IPython/core/magics/basic.py
@@ -571,9 +571,11 @@ def notebook(self, s):
             for output in outputs[execution_count]:
                 for mime_type, data in output.bundle.items():
                     if output.output_type == "out_stream":
-                        cell.outputs.append(v4.new_output("stream", text=[data]))
+                        text = data if isinstance(data, list) else [data]
+                        cell.outputs.append(v4.new_output("stream", text=text))
                     elif output.output_type == "err_stream":
-                        err_output = v4.new_output("stream", text=[data])
+                        text = data if isinstance(data, list) else [data]
+                        err_output = v4.new_output("stream", text=text)
                         err_output.name = "stderr"
                         cell.outputs.append(err_output)
                     elif output.output_type == "execute_result":


Test patch:
diff --git a/tests/test_interactiveshell.py b/tests/test_interactiveshell.py
index b16882265b4..be07aa127a4 100644
--- a/tests/test_interactiveshell.py
+++ b/tests/test_interactiveshell.py
@@ -16,6 +16,7 @@
 import shutil
 import sys
 import tempfile
+import time
 import unittest
 import pytest
 from unittest import mock
@@ -84,6 +85,15 @@ def test_run_cell_multiline(self):
         self.assertEqual(res.success, True)
         self.assertEqual(res.result, None)
 
+    def test_stream_performance(self):
+        """It should be fast to execute."""
+        src = "for i in range(250_000): print(i)"
+        start = time.perf_counter()
+        ip.run_cell(src)
+        end = time.perf_counter()
+        duration = end - start
+        assert duration < 10
+
     def test_multiline_string_cells(self):
         "Code sprinkled with multiline strings should execute (GH-306)"
         ip.run_cell("tmp=0")