diff --git a/airbrakes/airbrakes.py b/airbrakes/airbrakes.py
index a4519053..f52d7200 100644
--- a/airbrakes/airbrakes.py
+++ b/airbrakes/airbrakes.py
@@ -1,7 +1,6 @@
 """Module which provides a high level interface to the air brakes system on the rocket."""

 import time
-from collections import deque
 from typing import TYPE_CHECKING

 from airbrakes.hardware.base_imu import BaseIMU
@@ -84,7 +83,7 @@ def __init__(
         self.state: State = StandbyState(self)

         self.shutdown_requested = False
-        self.imu_data_packets: deque[IMUDataPacket] = deque()
+        self.imu_data_packets: list[IMUDataPacket] = []
         self.processor_data_packets: list[ProcessorDataPacket] = []
         self.apogee_predictor_data_packets: list[ApogeePredictorDataPacket] = []
         self.est_data_packets: list[EstimatedDataPacket] = []
@@ -137,7 +136,7 @@ def update(self) -> None:
         self.est_data_packets = [
             data_packet
             for data_packet in self.imu_data_packets
-            if isinstance(data_packet, EstimatedDataPacket)
+            if type(data_packet) is EstimatedDataPacket  # type() is ~55% faster than isinstance()
         ]

         # Update the processed data with the new data packets. We only care about EstDataPackets
diff --git a/airbrakes/hardware/base_imu.py b/airbrakes/hardware/base_imu.py
index 47a7d57a..4ac7777e 100644
--- a/airbrakes/hardware/base_imu.py
+++ b/airbrakes/hardware/base_imu.py
@@ -1,7 +1,6 @@
 """Module defining the base class (BaseIMU) for interacting with
 the IMU (Inertial measurement unit) on the rocket."""

-import collections
 import contextlib
 import sys

@@ -93,19 +92,18 @@ def get_imu_data_packet(self) -> IMUDataPacket | None:
         """
         return self._data_queue.get(timeout=IMU_TIMEOUT_SECONDS)

-    def get_imu_data_packets(self, block: bool = True) -> collections.deque[IMUDataPacket]:
+    def get_imu_data_packets(self, block: bool = True) -> list[IMUDataPacket]:
         """
         Returns all available data packets from the IMU.
-        :return: A deque containing the latest data packets from the IMU.
+        :return: A list containing the latest data packets from the IMU.
         """
-        # We use a deque because it's faster than a list for popping from the left
         try:
             packets = self._data_queue.get_many(
                 block=block, max_messages_to_get=MAX_FETCHED_PACKETS, timeout=IMU_TIMEOUT_SECONDS
             )
         except Empty:  # If the queue is empty (i.e. timeout hit), don't bother waiting.
-            return collections.deque()
+            return []
         else:
             if STOP_SIGNAL in packets:  # only used by the MockIMU
-                return collections.deque()
-            return collections.deque(packets)
+                return []
+            return packets
diff --git a/airbrakes/telemetry/logger.py b/airbrakes/telemetry/logger.py
index efad5bce..3d72d41b 100644
--- a/airbrakes/telemetry/logger.py
+++ b/airbrakes/telemetry/logger.py
@@ -18,7 +18,6 @@
     from queue import Empty

 import msgspec
-from msgspec.structs import asdict

 from airbrakes.constants import (
     BUFFER_SIZE_IN_BYTES,
@@ -32,6 +31,10 @@
 from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
 from airbrakes.telemetry.packets.processor_data_packet import ProcessorDataPacket

+DecodedLoggerDataPacket = list[int | float | str]
+"""The type of LoggerDataPacket after an instance of it was decoded from the queue. It is
+the same type as msgspec.to_builtins(LoggerDataPacket)."""
+

 class Logger:
     """
@@ -79,14 +82,17 @@ def __init__(self, log_dir: Path) -> None:
         # Create a new log file with the next number in sequence
         self.log_path = log_dir / f"log_{max_suffix + 1}.csv"
         with self.log_path.open(mode="w", newline="") as file_writer:
-            writer = csv.DictWriter(file_writer, fieldnames=list(LoggerDataPacket.__annotations__))
-            writer.writeheader()
+            headers = list(LoggerDataPacket.__struct_fields__)
+            writer = csv.writer(file_writer)
+            writer.writerow(headers)

         # Makes a queue to store log messages, basically it's a process-safe list that you add to
         # the back and pop from front, meaning that things will be logged in the order they were
         # added.
         # Signals (like stop) are sent as strings, but data is sent as dictionaries
         msgpack_encoder = msgspec.msgpack.Encoder(enc_hook=Logger._convert_unknown_type_to_str)
+        # No need to specify the type to decode to, since we want to log it immediately, so a list
+        # is wanted (and faster!):
         msgpack_decoder = msgspec.msgpack.Decoder()

         if sys.platform == "win32":
@@ -133,19 +139,20 @@ def _convert_unknown_type_to_str(obj_type: Any) -> str:
         return f"{obj_type:.8f}"

     @staticmethod
-    def _prepare_log_dict(
+    def _prepare_logger_packets(
         context_data_packet: ContextDataPacket,
         servo_data_packet: ServoDataPacket,
-        imu_data_packets: deque[IMUDataPacket],
+        imu_data_packets: list[IMUDataPacket],
         processor_data_packets: list[ProcessorDataPacket],
         apogee_predictor_data_packets: list[ApogeePredictorDataPacket],
     ) -> list[LoggerDataPacket]:
         """
-        Creates a data packet dictionary representing a row of data to be logged.
+        Creates a data packet representing a row of data to be logged.
         :param context_data_packet: The context data packet to log.
         :param servo_data_packet: The servo data packet to log.
         :param imu_data_packets: The IMU data packets to log.
-        :param processor_data_packets: The processor data packets to log.
+        :param processor_data_packets: The processor data packets to log. This is always the same
+        length as the number of EstimatedDataPackets present in the `imu_data_packets`.
         :param apogee_predictor_data_packets: The apogee predictor data packets to log.
         :return: A deque of LoggerDataPacket objects.
         """
@@ -153,46 +160,98 @@ def _prepare_log_dict(

         index = 0  # Index to loop over processor data packets:

-        # Convert the imu data packets to a dictionary:
+        # Convert the imu data packets to a LoggerDataPacket:
         for imu_data_packet in imu_data_packets:
-            # Let's first add the state, extension field:
-            logger_fields: LoggerDataPacket = {}
-            # Convert the packets to a dictionary
-            context_data_packet_dict: dict[str, int | str] = asdict(context_data_packet)
-            logger_fields.update(context_data_packet_dict)
-            servo_data_packet_dict: dict[str, int | float] = asdict(servo_data_packet)
-            logger_fields.update(servo_data_packet_dict)
-            # Using to_builtins() is much faster than asdict() for some reason, but we can't use
-            # that since that's going to convert it to a list because of array_like=True
-            imu_data_packet_dict: dict[str, int | float | list[str]] = asdict(imu_data_packet)
-            logger_fields.update(imu_data_packet_dict)
-
-            # Get the processor data packet fields:
-            if isinstance(imu_data_packet, EstimatedDataPacket):
-                processor_data_packet_dict: dict[str, float] = asdict(
-                    processor_data_packets[index],
-                )
-                # Let's drop the "time_since_last_data_packet" field:
-                processor_data_packet_dict.pop("time_since_last_data_packet", None)
-                logger_fields.update(processor_data_packet_dict)
+            logger_packet = LoggerDataPacket(
+                state_letter=context_data_packet.state_letter,
+                set_extension=servo_data_packet.set_extension,
+                encoder_position=servo_data_packet.encoder_position,
+                timestamp=imu_data_packet.timestamp,
+                invalid_fields=imu_data_packet.invalid_fields,
+                fetched_packets_in_main=context_data_packet.fetched_packets_in_main,
+                imu_queue_size=context_data_packet.imu_queue_size,
+                apogee_predictor_queue_size=context_data_packet.apogee_predictor_queue_size,
+                fetched_imu_packets=context_data_packet.fetched_imu_packets,
+                update_timestamp_ns=context_data_packet.update_timestamp_ns,
+            )

+            # Get the IMU data packet fields:
+            # Performance comparison (python 3.13.1 on x86_64 linux):
+            # - isinstance is 45.2% faster than match statement
+            # - hasattr is 20.57% faster than isinstance
+            # - type() is 34.85% faster than hasattr
+            if type(imu_data_packet) is EstimatedDataPacket:
+                # Extract all the fields from the EstimatedDataPacket
+                logger_packet.estOrientQuaternionW = imu_data_packet.estOrientQuaternionW
+                logger_packet.estOrientQuaternionX = imu_data_packet.estOrientQuaternionX
+                logger_packet.estOrientQuaternionY = imu_data_packet.estOrientQuaternionY
+                logger_packet.estOrientQuaternionZ = imu_data_packet.estOrientQuaternionZ
+                logger_packet.estPressureAlt = imu_data_packet.estPressureAlt
+                logger_packet.estAttitudeUncertQuaternionW = (
+                    imu_data_packet.estAttitudeUncertQuaternionW
+                )
+                logger_packet.estAttitudeUncertQuaternionX = (
+                    imu_data_packet.estAttitudeUncertQuaternionX
+                )
+                logger_packet.estAttitudeUncertQuaternionY = (
+                    imu_data_packet.estAttitudeUncertQuaternionY
+                )
+                logger_packet.estAttitudeUncertQuaternionZ = (
+                    imu_data_packet.estAttitudeUncertQuaternionZ
+                )
+                logger_packet.estAngularRateX = imu_data_packet.estAngularRateX
+                logger_packet.estAngularRateY = imu_data_packet.estAngularRateY
+                logger_packet.estAngularRateZ = imu_data_packet.estAngularRateZ
+                logger_packet.estCompensatedAccelX = imu_data_packet.estCompensatedAccelX
+                logger_packet.estCompensatedAccelY = imu_data_packet.estCompensatedAccelY
+                logger_packet.estCompensatedAccelZ = imu_data_packet.estCompensatedAccelZ
+                logger_packet.estLinearAccelX = imu_data_packet.estLinearAccelX
+                logger_packet.estLinearAccelY = imu_data_packet.estLinearAccelY
+                logger_packet.estLinearAccelZ = imu_data_packet.estLinearAccelZ
+                logger_packet.estGravityVectorX = imu_data_packet.estGravityVectorX
+                logger_packet.estGravityVectorY = imu_data_packet.estGravityVectorY
+                logger_packet.estGravityVectorZ = imu_data_packet.estGravityVectorZ
+
+                # Now also extract the fields from the ProcessorDataPacket
+                logger_packet.current_altitude = processor_data_packets[index].current_altitude
+                logger_packet.vertical_velocity = processor_data_packets[index].vertical_velocity
+                logger_packet.vertical_acceleration = processor_data_packets[
+                    index
+                ].vertical_acceleration
+
+                # Add index:
                 index += 1
-
-            # Apogee Prediction happens asynchronously, so we need to check if we have a packet
-            # This means that rather than belonging to a specific IMU packet, it belongs to the
-            # context packet.
+            else:  # It is a raw packet:
+                # Extract all the fields from the RawDataPacket
+                logger_packet.scaledAccelX = imu_data_packet.scaledAccelX
+                logger_packet.scaledAccelY = imu_data_packet.scaledAccelY
+                logger_packet.scaledAccelZ = imu_data_packet.scaledAccelZ
+                logger_packet.scaledGyroX = imu_data_packet.scaledGyroX
+                logger_packet.scaledGyroY = imu_data_packet.scaledGyroY
+                logger_packet.scaledGyroZ = imu_data_packet.scaledGyroZ
+                logger_packet.deltaVelX = imu_data_packet.deltaVelX
+                logger_packet.deltaVelY = imu_data_packet.deltaVelY
+                logger_packet.deltaVelZ = imu_data_packet.deltaVelZ
+                logger_packet.deltaThetaX = imu_data_packet.deltaThetaX
+                logger_packet.deltaThetaY = imu_data_packet.deltaThetaY
+                logger_packet.deltaThetaZ = imu_data_packet.deltaThetaZ
+                logger_packet.scaledAmbientPressure = imu_data_packet.scaledAmbientPressure
+
+            # Apogee Prediction happens asynchronously, so we need to check if we have a packet:

             # There is a small possibility that we won't log all the apogee predictor data packets
             # if the length of the IMU data packets is less than the length of the apogee predictor
             # data packets. However, this is unlikely to happen in practice. This particular case
             # is NOT covered by tests.
             if apogee_predictor_data_packets:
-                apogee_predictor_data_packet_dict: dict[str, float] = asdict(
-                    apogee_predictor_data_packets.pop(0),
-                )
-                logger_fields.update(apogee_predictor_data_packet_dict)
+                apogee_packet = apogee_predictor_data_packets.pop(0)
+                logger_packet.predicted_apogee = apogee_packet.predicted_apogee
+                logger_packet.a_coefficient = apogee_packet.a_coefficient
+                logger_packet.b_coefficient = apogee_packet.b_coefficient
+                logger_packet.uncertainty_threshold_1 = apogee_packet.uncertainty_threshold_1
+                logger_packet.uncertainty_threshold_2 = apogee_packet.uncertainty_threshold_2

-            logger_data_packets.append(logger_fields)
+            logger_data_packets.append(logger_packet)

         return logger_data_packets

@@ -216,7 +275,7 @@ def log(
         self,
         context_data_packet: ContextDataPacket,
         servo_data_packet: ServoDataPacket,
-        imu_data_packets: deque[IMUDataPacket],
+        imu_data_packets: list[IMUDataPacket],
         processor_data_packets: list[ProcessorDataPacket],
         apogee_predictor_data_packets: list[ApogeePredictorDataPacket],
     ) -> None:
@@ -228,8 +287,8 @@ def log(
         :param processor_data_packets: The processor data packets to log.
         :param apogee_predictor_data_packets: The apogee predictor data packets to log.
         """
-        # We are populating a dictionary with the fields of the logger data packet
-        logger_data_packets: list[LoggerDataPacket] = Logger._prepare_log_dict(
+        # We are populating a list with the fields of the logger data packet
+        logger_data_packets: list[LoggerDataPacket] = Logger._prepare_logger_packets(
             context_data_packet,
             servo_data_packet,
             imu_data_packets,
@@ -268,16 +327,13 @@ def _log_the_buffer(self):

     # ------------------------ ALL METHODS BELOW RUN IN A SEPARATE PROCESS -------------------------
     @staticmethod
-    def _truncate_floats(data: LoggerDataPacket) -> dict[str, str | object]:
+    def _truncate_floats(data: DecodedLoggerDataPacket) -> list[str | int]:
         """
-        Truncates the decimal place of the floats in the dictionary to 8 decimal places.
-        :param data: The dictionary to truncate.
-        :return: The truncated dictionary.
+        Truncates the decimal place of the floats in the list to 8 decimal places.
+        :param data: The list of values whose floats we should truncate.
+        :return: The truncated list.
         """
-        return {
-            key: f"{value:.8f}" if isinstance(value, float) else value
-            for key, value in data.items()
-        }
+        return [f"{value:.8f}" if isinstance(value, float) else value for value in data]

     def _logging_loop(self) -> None:  # pragma: no cover
         """
@@ -292,12 +348,12 @@ def _logging_loop(self) -> None:  # pragma: no cover

         # Set up the csv logging in the new process
         with self.log_path.open(mode="a", newline="") as file_writer:
-            writer = csv.DictWriter(file_writer, fieldnames=list(LoggerDataPacket.__annotations__))
+            writer = csv.writer(file_writer)
             while True:
                 # Get a message from the queue (this will block until a message is available)
                 # Because there's no timeout, it will wait indefinitely until it gets a message.
                 try:
-                    message_fields: list[LoggerDataPacket | Literal["STOP"]] = (
+                    message_fields: list[DecodedLoggerDataPacket | Literal["STOP"]] = (
                         self._log_queue.get_many(timeout=MAX_GET_TIMEOUT_SECONDS)
                     )
                 except Empty:
diff --git a/airbrakes/telemetry/packets/logger_data_packet.py b/airbrakes/telemetry/packets/logger_data_packet.py
index fbcbd405..c4578241 100644
--- a/airbrakes/telemetry/packets/logger_data_packet.py
+++ b/airbrakes/telemetry/packets/logger_data_packet.py
@@ -1,9 +1,9 @@
 """Module for describing the data packet for the logger to log"""

-from typing import TypedDict
+import msgspec

-class LoggerDataPacket(TypedDict, total=False):  # total=False means all fields are NotRequired
+class LoggerDataPacket(msgspec.Struct, array_like=True, kw_only=True):
     """
     Represents a collection of all data that the logger can log in a line. Not every field will be
     filled in every packet. The order in which the fields are defined determines the order in which
@@ -22,54 +22,56 @@ class LoggerDataPacket(TypedDict, total=False):  # total=False means all fields
     invalid_fields: list[str] | None

     # Raw Data Packet Fields
-    scaledAccelX: float | None
-    scaledAccelY: float | None
-    scaledAccelZ: float | None
-    scaledGyroX: float | None
-    scaledGyroY: float | None
-    scaledGyroZ: float | None
-    deltaVelX: float | None
-    deltaVelY: float | None
-    deltaVelZ: float | None
-    deltaThetaX: float | None
-    deltaThetaY: float | None
-    deltaThetaZ: float | None
-    scaledAmbientPressure: float | None
+    scaledAccelX: float | None = None
+    scaledAccelY: float | None = None
+    scaledAccelZ: float | None = None
+    scaledGyroX: float | None = None
+    scaledGyroY: float | None = None
+    scaledGyroZ: float | None = None
+    deltaVelX: float | None = None
+    deltaVelY: float | None = None
+    deltaVelZ: float | None = None
+    deltaThetaX: float | None = None
+    deltaThetaY: float | None = None
+    deltaThetaZ: float | None = None
+    scaledAmbientPressure: float | None = None

     # Estimated Data Packet Fields
-    estOrientQuaternionW: float | None
-    estOrientQuaternionX: float | None
-    estOrientQuaternionY: float | None
-    estOrientQuaternionZ: float | None
-    estPressureAlt: float | None
-    estAttitudeUncertQuaternionW: float | None
-    estAttitudeUncertQuaternionX: float | None
-    estAttitudeUncertQuaternionY: float | None
-    estAttitudeUncertQuaternionZ: float | None
-    estAngularRateX: float | None
-    estAngularRateY: float | None
-    estAngularRateZ: float | None
-    estCompensatedAccelX: float | None
-    estCompensatedAccelY: float | None
-    estCompensatedAccelZ: float | None
-    estLinearAccelX: float | None
-    estLinearAccelY: float | None
-    estLinearAccelZ: float | None
-    estGravityVectorX: float | None
-    estGravityVectorY: float | None
-    estGravityVectorZ: float | None
+    estOrientQuaternionW: float | None = None
+    estOrientQuaternionX: float | None = None
+    estOrientQuaternionY: float | None = None
+    estOrientQuaternionZ: float | None = None
+    estPressureAlt: float | None = None
+    estAttitudeUncertQuaternionW: float | None = None
+    estAttitudeUncertQuaternionX: float | None = None
+    estAttitudeUncertQuaternionY: float | None = None
+    estAttitudeUncertQuaternionZ: float | None = None
+    estAngularRateX: float | None = None
+    estAngularRateY: float | None = None
+    estAngularRateZ: float | None = None
+    estCompensatedAccelX: float | None = None
+    estCompensatedAccelY: float | None = None
+    estCompensatedAccelZ: float | None = None
+    estLinearAccelX: float | None = None
+    estLinearAccelY: float | None = None
+    estLinearAccelZ: float | None = None
+    estGravityVectorX: float | None = None
+    estGravityVectorY: float | None = None
+    estGravityVectorZ: float | None = None

     # Processor Data Packet Fields
-    current_altitude: float | None
-    vertical_velocity: float | None
-    vertical_acceleration: float | None
+    current_altitude: float | None = None
+    vertical_velocity: float | None = None
+    vertical_acceleration: float | None = None

     # Apogee Predictor Data Packet Fields
-    predicted_apogee: float | None
-    a_coefficient: float | None
-    b_coefficient: float | None
-    uncertainty_threshold_1: float | None
-    uncertainty_threshold_2: float | None
+    # These fields are "str" because they were numpy.float64, which is converted to a string
+    # when encoded in the logger. Encoding directly to string with 8 decimal places truncation.
+    predicted_apogee: str | None = None
+    a_coefficient: str | None = None
+    b_coefficient: str | None = None
+    uncertainty_threshold_1: str | None = None
+    uncertainty_threshold_2: str | None = None

     # Other fields in ContextDataPacket
     fetched_packets_in_main: int | None
diff --git a/airbrakes/utils.py b/airbrakes/utils.py
index bedee488..706c86f1 100644
--- a/airbrakes/utils.py
+++ b/airbrakes/utils.py
@@ -7,8 +7,11 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Any

+import msgspec
 import psutil

+from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
+
 if TYPE_CHECKING:
     import multiprocessing

@@ -25,7 +28,14 @@ def get_always_list(self, *args, **kwargs) -> list:
     as the multiprocessing.Queue doesn't have a `get_many` method"""
     fetched = self.get(*args, **kwargs)
     if isinstance(fetched, list):
+        if isinstance(fetched[0], LoggerDataPacket):
+            # Return the encoded packet as a list:
+            return [
+                msgspec.to_builtins(packet, enc_hook=_convert_unknown_type_to_float)
+                for packet in fetched
+            ]
         return fetched
+
     return [fetched]

diff --git a/tests/auxil/utils.py b/tests/auxil/utils.py
index 3db14c63..24b1d76f 100644
--- a/tests/auxil/utils.py
+++ b/tests/auxil/utils.py
@@ -3,6 +3,7 @@
 from airbrakes.telemetry.packets.apogee_predictor_data_packet import ApogeePredictorDataPacket
 from airbrakes.telemetry.packets.context_data_packet import ContextDataPacket
 from airbrakes.telemetry.packets.imu_data_packet import EstimatedDataPacket, RawDataPacket
+from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
 from airbrakes.telemetry.packets.processor_data_packet import ProcessorDataPacket
 from airbrakes.telemetry.packets.servo_data_packet import ServoDataPacket

@@ -57,3 +58,11 @@ def make_apogee_predictor_data_packet(**kwargs) -> ApogeePredictorDataPacket:

     dummy_values = {k: 0.123456789 for k in ApogeePredictorDataPacket.__struct_fields__}
     return ApogeePredictorDataPacket(**{**dummy_values, **kwargs})
+
+
+def make_logger_data_packet(**kwargs) -> LoggerDataPacket:
+    """Creates a LoggerDataPacket with the specified keyword arguments. Provides dummy values for
+    arguments not specified."""
+
+    dummy_values = {k: "test" for k in LoggerDataPacket.__struct_fields__}
+    return LoggerDataPacket(**{**dummy_values, **kwargs})
diff --git a/tests/test_components/test_logger.py b/tests/test_components/test_logger.py
index 0927b21c..3957a2be 100644
--- a/tests/test_components/test_logger.py
+++ b/tests/test_components/test_logger.py
@@ -73,12 +73,18 @@ def convert_dict_vals_to_str(d: dict[str, float], truncation: bool = True) -> di
 class TestLogger:
     """Tests the Logger() class in logger.py"""

-    sample_data = {
-        "state_letter": "S",
-        "set_extension": "0.0",
-        "timestamp": "4",
-        "invalid_fields": "[]",
-    }
+    sample_ldp = LoggerDataPacket(
+        state_letter="S",
+        set_extension="0.0",
+        timestamp=4,
+        invalid_fields=[],
+        encoder_position=None,
+        fetched_imu_packets=None,
+        fetched_packets_in_main=None,
+        imu_queue_size=None,
+        apogee_predictor_queue_size=None,
+        update_timestamp_ns=None,
+    )

     @pytest.fixture(autouse=True)  # autouse=True means run this function before/after every test
     def _clear_directory(self):
@@ -125,7 +131,7 @@ def test_init_log_file_has_correct_headers(self, logger):
         with logger.log_path.open() as f:
             reader = csv.DictReader(f)
             keys = reader.fieldnames
-            assert list(keys) == list(LoggerDataPacket.__annotations__)
+            assert list(keys) == list(LoggerDataPacket.__struct_fields__)

     def test_log_buffer_is_full_property(self, logger):
         """Tests whether the property is_log_buffer_full works correctly."""
@@ -156,7 +162,7 @@ def test_logging_loop_start_stop(self, logger):

     def test_logger_stop_logs_the_buffer(self, logger):
         logger.start()
-        logger._log_buffer.appendleft(self.sample_data)
+        logger._log_buffer.appendleft(self.sample_ldp)
         logger.stop()
         with logger.log_path.open() as f:
             reader = csv.DictReader(f)
@@ -166,7 +172,7 @@ def test_logger_stop_logs_the_buffer(self, logger):
                 assert len(row) > 5  # Should have other fields with empty values
                 # Only fetch non-empty values:
                 row_dict = {k: v for k, v in row.items() if v}
-                assert row_dict == self.sample_data
+                assert row_dict == convert_dict_vals_to_str(asdict(self.sample_ldp), False)
             assert count == 1
         assert len(logger._log_buffer) == 0

@@ -196,14 +202,8 @@ def _logging_loop_patched(self):
         assert values.qsize() == 1
         assert values.get() == "clean exit"

-    def test_logger_loop_exception_raised(self, logger):
-        """Tests whether the Logger loop properly propogates unknown exceptions."""
-        logger._log_queue.put({"wrong_filed": "wrong_value"})
-        with pytest.raises(ValueError, match="dict contains fields not in fieldnames"):
-            logger._logging_loop()
-
     def test_logging_loop_add_to_queue(self, logger):
-        logger._log_queue.put(self.sample_data)
+        logger._log_queue.put(self.sample_ldp)
         assert logger._log_queue.qsize() == 1
         logger.start()
         time.sleep(0.01)  # Give the process time to log to file
@@ -216,14 +216,14 @@ def test_logging_loop_add_to_queue(self, logger):
             # Only fetch non-empty values:
             row_dict = {k: v for k, v in row.items() if v}

-            assert row_dict == self.sample_data
+            assert row_dict == convert_dict_vals_to_str(asdict(self.sample_ldp), truncation=False)

     def test_queue_hits_timeout_and_continues(self, logger, monkeypatch):
         """Tests whether the logger continues to log after a timeout."""
         monkeypatch.setattr("airbrakes.telemetry.logger.MAX_GET_TIMEOUT_SECONDS", 0.01)
         logger.start()
         time.sleep(0.05)
-        logger._log_queue.put(self.sample_data)
+        logger._log_queue.put(self.sample_ldp)
         logger.stop()
         # Let's check the contents of the file:
         with logger.log_path.open() as f:
@@ -234,7 +234,7 @@ def test_queue_hits_timeout_and_continues(self, logger, monkeypatch):
             # Only fetch non-empty values:
             row_dict = {k: v for k, v in row.items() if v}

-            assert row_dict == self.sample_data
+            assert row_dict == convert_dict_vals_to_str(asdict(self.sample_ldp), truncation=False)

     # This decorator is used to run the same test with different data
     # read more about it here: https://docs.pytest.org/en/stable/parametrize.html
@@ -427,11 +427,11 @@ def test_log_capacity_exceeded_standby(
         processor_data_packets: list[ProcessorDataPacket],
         apogee_predictor_data_packets: list[ApogeePredictorDataPacket],
         monkeypatch,
+        logger,
     ):
         """Tests whether the log buffer works correctly for the Standby and Landed state."""

-        monkeypatch.setattr(Logger, "stop", patched_stop)
-        logger = Logger(LOG_PATH)
+        monkeypatch.setattr(logger.__class__, "stop", patched_stop)
         # Test if the buffer works correctly
         logger.start()
         # Log more than IDLE_LOG_CAPACITY packets to test if it stops logging after LOG_BUFFER_SIZE.
@@ -710,12 +710,11 @@ def test_log_buffer_reset_after_landed(
                 [],
                 [],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="S")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_raw_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
-                    }
+                    )
                 ],
             ),
             (
@@ -725,13 +724,12 @@ def test_log_buffer_reset_after_landed(
                 [make_processor_data_packet()],
                 [],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="S")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_est_data_packet()),
-                        **asdict(make_processor_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
-                    }
+                        **only_logged_pdp_fields(asdict(make_processor_data_packet())),
+                    ),
                 ],
             ),
             (
@@ -741,18 +739,17 @@ def test_log_buffer_reset_after_landed(
                 [make_processor_data_packet()],
                 [],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="M")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_raw_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
-                    },
-                    {
+                    ),
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="M")),
                         **asdict(make_servo_data_packet(set_extension="0.1")),
                         **asdict(make_est_data_packet()),
-                        **asdict(make_processor_data_packet()),
-                    },
+                        **only_logged_pdp_fields(asdict(make_processor_data_packet())),
+                    ),
                 ],
             ),
             (
@@ -762,19 +759,18 @@ def test_log_buffer_reset_after_landed(
                 [make_processor_data_packet()],
                 [make_apogee_predictor_data_packet()],
                 [
-                    {
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="C")),
                         **asdict(make_servo_data_packet(set_extension="0.5")),
                         **asdict(make_raw_data_packet()),
-                        **remove_state_letter(asdict(make_context_data_packet())),
                         **asdict(make_apogee_predictor_data_packet()),
-                    },
-                    {
+                    ),
+                    LoggerDataPacket(
                         **asdict(make_context_data_packet(state_letter="C")),
                         **asdict(make_servo_data_packet(set_extension="0.5")),
                         **asdict(make_est_data_packet()),
-                        **asdict(make_processor_data_packet()),
-                    },
+                        **only_logged_pdp_fields(asdict(make_processor_data_packet())),
+                    ),
                 ],
             ),
         ],
@@ -785,7 +781,7 @@ def test_log_buffer_reset_after_landed(
             "both in coast",
         ],
     )
-    def test_prepare_log_dict(
+    def test_prepare_logger_packets(
         self,
         logger,
         context_packet,
@@ -795,18 +791,18 @@ def test_prepare_log_dict(
         apogee_predictor_data_packet,
         expected_outputs,
     ):
-        """Tests whether the _prepare_log_dict method creates the correct LoggerDataPackets."""
+        """Tests whether the _prepare_logger_packets method creates correct LoggerDataPackets."""

         # set some invalid fields to test if they stay as a list
-        invalid_field = ["something"]
+        invalid_field = ["something", "hey"]
         for imu_packet in imu_data_packets:
             imu_packet.invalid_fields = invalid_field
         # we need to change the output also
         for expected in expected_outputs:
-            expected["invalid_fields"] = invalid_field
+            expected.invalid_fields = invalid_field

         # Now let's test the method
-        logger_data_packets = logger._prepare_log_dict(
+        logger_data_packets = logger._prepare_logger_packets(
             context_packet,
             servo_packet,
             imu_data_packets,
@@ -817,20 +813,11 @@ def test_prepare_log_dict(
         assert len(logger_data_packets) == len(expected_outputs)

         for logger_data_packet, expected in zip(logger_data_packets, expected_outputs, strict=True):
-            assert isinstance(logger_data_packet, dict)
-            converted = logger_data_packet
-
-            # certain fields are not converted to strings (intentionally. See logger.py)
-            assert isinstance(converted["invalid_fields"], list)
-            assert isinstance(converted["timestamp"], int)
-
-            # Remove "time_since_last_data_packet" from the expected output, since we don't log that
-            expected.pop("time_since_last_data_packet", None)
-
-            assert converted == expected
+            assert isinstance(logger_data_packet, LoggerDataPacket)
+            assert logger_data_packet == expected

     def test_benchmark_log_method(self, benchmark, logger):
-        """Tests the performance of the _prepare_log_dict method."""
+        """Tests the performance of the _prepare_logger_packets method."""
         context_packet = make_context_data_packet(state_letter="S")
         servo_packet = make_servo_data_packet(set_extension="0.1")
         imu_data_packets = deque([make_raw_data_packet()])
@@ -846,8 +833,8 @@ def test_benchmark_log_method(self, benchmark, logger):
             apogee_predictor_data_packet,
         )

-    def test_benchmark_prepare_log_dict(self, benchmark, logger):
-        """Tests the performance of the _prepare_log_dict method."""
+    def test_benchmark_prepare_logger_packets(self, benchmark, logger):
+        """Tests the performance of the _prepare_logger_packets method."""
         context_packet = make_context_data_packet(state_letter="S")
         servo_packet = make_servo_data_packet(set_extension="0.1")
         imu_data_packets = deque([make_raw_data_packet()])
@@ -855,7 +842,7 @@ def test_benchmark_prepare_log_dict(self, benchmark, logger):
         apogee_predictor_data_packet = [make_apogee_predictor_data_packet()]

         benchmark(
-            logger._prepare_log_dict,
+            logger._prepare_logger_packets,
             context_packet,
             servo_packet,
             imu_data_packets,
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 1d5f77ba..1b1190a6 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -250,7 +250,7 @@ def test_update(
             reader = csv.DictReader(f)
             # Check if all headers were logged:
             headers = reader.fieldnames
-            assert list(headers) == list(LoggerDataPacket.__annotations__)
+            assert list(headers) == list(LoggerDataPacket.__struct_fields__)

             # Let's just test the first line (excluding the headers) for a few things:
             line = next(reader)
diff --git a/tests/test_packets/test_logged_data_packet.py b/tests/test_packets/test_logged_data_packet.py
index 10fb647b..d71887e5 100644
--- a/tests/test_packets/test_logged_data_packet.py
+++ b/tests/test_packets/test_logged_data_packet.py
@@ -6,35 +6,25 @@
 from airbrakes.telemetry.packets.logger_data_packet import LoggerDataPacket
 from airbrakes.telemetry.packets.processor_data_packet import ProcessorDataPacket
 from airbrakes.telemetry.packets.servo_data_packet import ServoDataPacket
+from tests.auxil.utils import make_logger_data_packet

 @pytest.fixture
 def logger_data_packet():
-    return LoggerDataPacket(
-        state_letter=TestLoggerDataPacket.state_letter,
-        set_extension=TestLoggerDataPacket.set_extension,
-        timestamp=TestLoggerDataPacket.timestamp,
-    )
+    return make_logger_data_packet()

 class TestLoggerDataPacket:
     """Tests for the LoggerDataPacket class."""

-    state_letter = "test"
-    set_extension = "0.0"
-    timestamp = 10
-
     def test_init(self, logger_data_packet):
         packet = logger_data_packet
-        assert isinstance(packet, dict)
-        assert packet["state_letter"] == self.state_letter
-        assert packet["set_extension"] == self.set_extension
-        assert packet["timestamp"] == self.timestamp
+        assert isinstance(packet, LoggerDataPacket)

     def test_logger_data_packet_has_all_fields_of_data_packets(self):
         """Tests whether the LoggerDataPacket class has all the fields of the data packet
         classes."""
-        log_dp_fields = set(LoggerDataPacket.__annotations__)
+        log_dp_fields = set(LoggerDataPacket.__struct_fields__)

         context_dp_fields = set(ContextDataPacket.__struct_fields__)
         servo_dp_fields = set(ServoDataPacket.__struct_fields__)
@@ -82,7 +72,7 @@ def test_logger_data_packet_field_order(self):
         # Only context data packet has a different order - one field - "state_letter" is in the
         # beginning, the rest at the end.

-        log_dp_fields = list(LoggerDataPacket.__annotations__)
+        log_dp_fields = list(LoggerDataPacket.__struct_fields__)
         context_dp_fields = list(ContextDataPacket.__struct_fields__)
         servo_dp_fields = list(ServoDataPacket.__struct_fields__)
         raw_dp_fields = list(RawDataPacket.__struct_fields__)

Refactor logging to be more clear and performant
After the changes in #146 in the logger to get #147 resolved, our logging process has slightly slowed down due to use of `msgpec.structs.asdict()` instead of `msgspec.to_builtins()`. We can still keep up with the IMU process, but the margin has reduced.

I propose the following optimizations:

1. Make `LoggerDataPacket` a msgspec Struct again. Cost of initializing a new Struct is much cheaper than converting existing ones to a dict anyway.
2. The main process shouldn't be making the LoggerDataPacket into a dict, we can send it to the Logger process as a struct, and take advantage of the de/encoder now.

Rejected idea:
1. We technically don't need to attach `Context` and `Servo` packets to every LoggerDataPacket, as that just increases the amount of bytes we need to transfer to the other process. But separating that from `LoggerDataPacket` is a bit too unclean, confusing, and increases development time.


diff --git a/thunder/core/utils.py b/thunder/core/utils.py
index dc4e6f345d..cc5c5995cc 100644
--- a/thunder/core/utils.py
+++ b/thunder/core/utils.py
@@ -17,7 +17,7 @@
 from thunder.core.proxies import Proxy, NumberProxy, TensorProxy, variableify, CONSTRAINT, Variable
 from thunder.core.baseutils import *
 from thunder.core.codeutils import *
-from thunder.core.trace import TraceCtx
+from thunder.core.trace import TraceCtx, tracectx
 import thunder.core.prims as prims

 if TYPE_CHECKING:
@@ -1246,3 +1246,16 @@ def get_dtype_for_device_if_enabled(self, query_device_str: str):

         # Not found on the stack.
         return None
+
+
+def create_python_callable_from_bsym(bsym: BoundSymbolInterface) -> str:
+    trace = TraceCtx()
+    si = SigInfo(bsym.sym.name)
+    si.args = [(v.name, None) for v in bsym.flat_args]
+    trace._siginfo = si
+    trace.bound_symbols = list(bsym.subsymbols)
+
+    with tracectx(trace):
+        prims.python_return(bsym.output)
+
+    return trace.python(include_decorators=False)
diff --git a/thunder/dynamo/report.py b/thunder/dynamo/report.py
index 34d55edcea..8ebb265f2d 100644
--- a/thunder/dynamo/report.py
+++ b/thunder/dynamo/report.py
@@ -10,10 +10,22 @@

 import torch
 from thunder.core.pytree import tree_flatten
-from thunder.core.utils import sequencify
+from thunder.core.utils import sequencify, create_python_callable_from_bsym
 from thunder.dynamo.compiler import thunderfx
-from thunder.dynamo.utils import _get_example_inputs_from_placeholder, _readable, arg_like, get_env
-from thunder.dynamo.repro_script_template import benchmark_multi_exe_code_template, repro_code_template
+from thunder.dynamo.utils import (
+    _get_example_inputs_from_placeholder,
+    _readable,
+    arg_like,
+    get_env,
+    get_split_reasons_string,
+    CompilerType,
+)
+from thunder.dynamo.repro_script_template import (
+    benchmark_multi_exe_code_template,
+    repro_code_template,
+    bsym_torch_compile_repro_template,
+)
+from thunder import last_traces, last_backward_traces

 if TYPE_CHECKING:
@@ -22,6 +34,9 @@
     from collections.abc import Sequence

     from thunder.dynamo.utils import ExampleInputMetaData
+    from thunder.dev_utils.utils import BenchmarkComparisonData
+    from thunder.core.trace import TraceCtx
+    from thunder.core.symbol import BoundSymbol

 def run_backward(fn, *args, **kwargs):
@@ -396,7 +411,7 @@ def __init__(self, graphs: list[torch.fx.GraphModule], graph_names: list[str] =
             graph_names = [f"graph{idx}" for idx in range(len(graphs))]
         self.fx_graph_reports: list[FXGraphReport] = [FXGraphReport(g, name) for name, g in zip(graph_names, graphs)]

-    def __str__(self):
+    def __repr__(self):
         return f"<FXReport with {len(self.fx_graph_reports)} FXGraphReports accessible via .fx_graph_reports>"

@@ -464,3 +479,313 @@ def helper_backend(gm, example_inputs):
     compiled(*args, **kwargs)

     return FXReport(graphs)
+
+
+class ThunderSplitGraphReport(FXGraphReport):
+    """
+    A report class representing a Thunder-split FX subgraph, extending :class:FXGraphReport.
+
+    This class encapsulates details about a subgraph that was split from the original
+    Dynamo FX graph due to Thunder-specific transformations.
+
+    Attributes:
+        graph: The Thunder-split FX graph.
+        graph_name: The name of the Thunder-split FX graph.
+        compiled_fn: The Thunder-compiled function corresponding to :attr:`graph`.
+        example_input: An example input used for execution.
+        thunder_options: Configuration options specific to :func:`thunder.jit`.
+        split_reason: The reason why :func:`thunder.dynamo.splitter._splitter` split the original graph.
+        fusion_reports (list[ThunderFusionReport]): A list of fusion reports for the
+            nvFusion regions generated when using the nvFuser executor.
+            See :class:`ThunderFusionReport` for more details.
+        fwd_trc: The forward trace, available only after calling :meth:`_create_thunder_traces`.
+        bwd_trc: The backward trace, available only after calling :meth:`_create_thunder_traces`.
+
+    For an example, see the documentation of :func:`analyze_thunder_splits`.
+    """
+
+    def __init__(
+        self,
+        graph: torch.fx.GraphModule,
+        graph_name: str,
+        compiled_fn: Callable,
+        example_input,
+        thunder_options: dict,
+        split_reason: str,
+    ):
+        super().__init__(graph, graph_name)
+        self.compiled_fn = compiled_fn
+        self.example_input = example_input
+        self.thunder_options = thunder_options
+        self.split_reason = split_reason
+
+        self.fusion_reports: list[ThunderFusionReport] = []
+        self.fwd_trc: TraceCtx = None
+        self.bwd_trc: TraceCtx = None
+
+    def __repr__(self):
+        return f"<ThunderSplitGraphReport with {len(self.fusion_reports)} ThunderFusionReport accessible via .fusion_reports>"
+
+    def _create_thunder_traces(self):
+        example_inputs = eval(f"[\n{chr(10).join(arg_like(a) for a in self.example_input)}]")
+        # Executes to get the trace
+        run_backward(self.compiled_fn, *example_inputs)
+        self.fwd_trc = last_traces(self.compiled_fn)[-1]
+        self.bwd_trc = last_backward_traces(self.compiled_fn)[-1]
+
+    def create_fusion_reports(self):
+        """
+        Runs the Thunder-compiled function to obtain the nvFusion definition
+        and generate the :class:`ThunderFusionReport` instance based on it.
+        """
+        self._create_thunder_traces()
+        for trace, prefix in [(self.fwd_trc, "forward"), (self.bwd_trc, "backward")]:
+            for bsym in trace.bound_symbols:
+                if bsym.sym.is_fusion and "nvFusion" in bsym.sym.name:
+                    self.fusion_reports.append(ThunderFusionReport(bsym, f"{self.graph_name}_{bsym.sym.name}_{prefix}"))
+
+    def write_thunder_repro(self, folder, use_benchmark=False, serialize_inputs=False):
+        thunder_ex_str = (
+            f"partial(thunder.jit, {self.thunder_options})" if self.thunder_options is None else "thunder.jit"
+        )
+        has_cuda_args = any(hasattr(arg, "device") and arg.device.type == "cuda" for arg in self.example_input)
+        import_str = ["import thunder", "from functools import partial"]
+        if has_cuda_args:
+            # Since Thunder compile options don't clearly indicate required imports,
+            # we include commonly used transforms by default.
+            import_str.extend(
+                [
+                    "from thunder.transforms.cudagraph import CUDAGraphTransform",
+                    "from thunder.dev_utils.nvtx_profile_transform import NvtxProfileTransform",
+                ]
+            )
+        if not use_benchmark:
+            super().write_repro(
+                folder,
+                f"{self.graph_name}_repro_thunder.py",
+                executor_str=thunder_ex_str,
+                import_str=import_str,
+                serialize_inputs=serialize_inputs,
+                inputs=self.example_input,
+                extra_comment_str=self.split_reason,
+            )
+            return
+
+        executor_names_list = ["thunder"]
+        executors = [thunder_ex_str]
+
+        super().write_benchmark_repro(
+            folder,
+            f"{self.graph_name}_benchmark_thunder.py",
+            executor_names_list,
+            executor_str=executors,
+            import_str=import_str,
+            serialize_inputs=serialize_inputs,
+            inputs=self.example_input,
+            extra_comment_str=self.split_reason,
+        )
+
+
+class ThunderFusionReport:
+    """
+    A report class representing a Thunder nvFusion region.
+
+    This class encapsulates information about a nvFusion region created during
+    Thunder execution, including the symbolic representation and its name.
+
+    Attributes:
+        nvfusion_bsym (BoundSymbol): The symbolic representation of the nvFusion region.
+        name (str): The name of the fusion region.
+
+    For an example, see the documentation of :func:`analyze_thunder_splits`.
+    """
+
+    def __init__(self, bsym: BoundSymbol, name: str):
+        self.nvfusion_bsym = bsym
+        self.name = name
+
+    def __repr__(self):
+        return f"<ThunderFusionReport of bound symbol\n{self.nvfusion_bsym}>"
+
+    def write_nvfuser_repro(self, folder, file_name=None):
+        folder = Path(folder)
+        folder.mkdir(exist_ok=True, parents=True)
+        nvfuser_callable = self.nvfusion_bsym._call_ctx[self.nvfusion_bsym.sym.name]
+        fd = nvfuser_callable.last_used
+
+        # The API for nvFuser version >=2.14
+        get_repro = getattr(fd, "repro_script_for", None)
+        # The legacy nvFuser API
+        if get_repro is None:
+            get_repro = getattr(fd, "getReproString", None)
+        if get_repro is None:
+            raise RuntimeError("The installed version of nvFuser does not support repro generation unless on crash.")
+
+        inputs = self.get_inputs()
+        repro_code = get_repro(inputs)
+        comment_str = f'"""\n{self.nvfusion_bsym}\n"""'
+
+        if file_name == None:
+            file_name = f"{self.name}_repro_nvfuser.py"
+        with open(folder / file_name, "w") as f:
+            print(comment_str, file=f)
+            print(repro_code, file=f)
+
+    def get_inputs(self):
+        return self.nvfusion_bsym._call_ctx[self.nvfusion_bsym.sym.name].last_inputs
+
+    def write_inductor_repro(self, folder: PathLike, file_name=None):
+        folder = Path(folder)
+        folder.mkdir(exist_ok=True, parents=True)
+        python_func = create_python_callable_from_bsym(self.nvfusion_bsym)
+        nvfusion_name = self.nvfusion_bsym.sym.name
+
+        inputs = self.get_inputs()
+        inputs = "[" + "".join(arg_like(inp) for inp in inputs) + "]"
+        program = bsym_torch_compile_repro_template.format(
+            python_func=python_func, func_name=nvfusion_name, inputs=inputs
+        )
+        if file_name == None:
+            file_name = f"{self.name}_repro_inductor.py"
+        with open(folder / file_name, "w") as f:
+            f.write(program)
+
+    def run_benchmark(self) -> BenchmarkComparisonData:
+        from thunder.dev_utils.utils import _benchmark_fusion_region_with_nvfuser_and_torch_compile
+
+        return _benchmark_fusion_region_with_nvfuser_and_torch_compile(self.nvfusion_bsym)
+
+
+class ThunderFXGraphReport(FXGraphReport):
+    """
+    A Thunder-specific report class for a Dynamo FX Graph, extending FXGraphReport,
+    providing the ability to save reproduction/benchmark scripts for the original FX graph.
+    Additionally, it includes information about Thunder-split subgraphs in `subgraph_reports`.
+
+    Attributes:
+        graph (torch.fx.GraphModule): The original Dynamo FX graph before being split by Thunder.
+        graph_name (str): The name of the original Dynamo FX graph.
+        split_reason (str): Reasons explaining why the subgraph was split.
+        subgraph_reports (list[ThunderSplitGraphReport]): A list of reports for each
+            Thunder-split FX graph. For more details, see :class:`ThunderSplitGraphReport`.
+
+    For an example, see the documentation of :func:`analyze_thunder_splits`.
+    """
+
+    def __init__(
+        self, gm: torch.fx.GraphModule, gm_name: str, split_reason: str, subgraph_reports: list[ThunderSplitGraphReport]
+    ):
+        super().__init__(gm, gm_name)
+        self.split_reason = split_reason
+        self.subgraph_reports: list[ThunderSplitGraphReport] = subgraph_reports
+
+    def __repr__(self):
+        return f"<ThunderFXGraphReport with {len(self.subgraph_reports)} ThunderSplitGraphReport accessible via .subgraph_reports>"
+
+
+def analyze_thunder_splits(
+    report: FXGraphReport,
+    **thunder_options,
+) -> ThunderFXGraphReport:
+    """
+    Generates a :class:`ThunderFXGraphReport` based on an :class:`FXGraphReport`.
+
+    The :class:`ThunderFXGraphReport` provides additional details about Thunder-specific splits
+    and nvFusion regions. For more details, see :class:`ThunderFXGraphReport`.
+
+    Example:
+        .. code-block:: python
+
+        import tempfile
+        from pathlib import Path
+        import torch
+        from thunder.dynamo.report import (
+            fx_report, FXReport, ThunderFXGraphReport, FXGraphReport,
+            ThunderSplitGraphReport, ThunderFusionReport, analyze_thunder_splits
+        )
+        from pathlib import Path
+
+        x = torch.ones(2, 2, device="cuda", requires_grad=True)
+
+        # Dynamo segments `foo` into two graphs. Each graph contains one Thunder-split graph,
+        # and each Thunder-split graph has one nvFusion region.
+        def foo(x):
+            x = x.exp()
+            torch._dynamo.graph_break()
+            y = torch.sinc(x) + torch.cos(x)
+            return y + 1
+
+        # If using `torch.compile` alone, you can stop here and query the reports in `FXReport`.
+        # For more details, see the example in :func:`fx_report`.
+        results: FXReport = fx_report(foo, x)
+
+        fx_graph_report: FXGraphReport
+        with tempfile.TemporaryDirectory() as tmpdir:
+            tmp_path = Path(tmpdir)
+            for idx, fx_graph_report in enumerate(results.fx_graph_reports):
+                # `ThunderFXGraphReport` extends `FXGraphReport`, providing the ability to save
+                # reproduction/benchmark scripts for the original FX graph. Additionally, it
+                # includes information about Thunder-split subgraphs in `subgraph_reports`.
+                thunder_fx_graph_report: ThunderFXGraphReport = analyze_thunder_splits(fx_graph_report)
+                # Saves a reproduction script for the original FX graph
+                thunder_fx_graph_report.write_thunder_repro(tmp_path)
+
+                thunder_split_report: ThunderSplitGraphReport
+                for thunder_split_report in thunder_fx_graph_report.subgraph_reports:
+                    split_folder = tmp_path / str(idx)
+                    thunder_split_report.write_eager_repro(split_folder)
+                    thunder_split_report.write_thunder_repro(split_folder)
+                    thunder_split_report.write_inductor_repro(split_folder)
+
+                    # If you are only interested in the Thunder-split FX graph, you can stop here.
+                    # If you want to inspect Thunder traces and nvFusion regions further, explicitly call
+                    # `ThunderSplitGraphReport.create_fusion_reports()` and analyze as shown below.
+                    thunder_split_report.create_fusion_reports()
+                    print(f"fwd_trace:\n{thunder_split_report.fwd_trc}\n")
+                    print(f"bwd_trace:\n{thunder_split_report.bwd_trc}\n")
+                    nvfusion_report: ThunderFusionReport
+                    for nvfusion_report in thunder_split_report.fusion_reports:
+                        nvfusion_report.write_nvfuser_repro(split_folder / "nvfusion")
+                        nvfusion_report.write_inductor_repro(split_folder / "nvfusion")
+                        bench_data = nvfusion_report.run_benchmark()
+                        print(bench_data)
+                        print("---"*10)
+
+    """
+    from thunder.dynamo.utils import remove_empty_autocast, recompile_graph, get_thunder_module_names
+    from thunder.dynamo.splitter import _splitter
+    from thunder import jit
+
+    # Splits the FX graph module using Thunder splitter
+    gm = remove_empty_autocast(report.graph)
+    # Dynamo uses lazy generation of the underlying Python code, so we need to
+    # force recompilation of the GraphModule before passing it to Thunder.
+    recompile_graph(gm)
+    _thunder_jit = partial(jit, **thunder_options, nv_store_fusion_inputs=True)
+    _, subgraph_info = _splitter(gm, _thunder_jit, torch.compile, _unused_sample_args=None)
+
+    thunder_module_names = [f"{report.graph_name}_{name}" for name in get_thunder_module_names(subgraph_info)]
+    original_modules_to_thunder_modules = (
+        [m, compiled_m]
+        for m, compiled_m in subgraph_info.submodule_to_compiled_functions.items()
+        if compiled_m.compiler == CompilerType.THUNDER
+    )
+    example_inputs = subgraph_info.thunder_compiled_fns_example_inputs
+    split_reason = get_split_reasons_string(subgraph_info)
+
+    subgraph_reports = []
+    for name, sub_gm_pair, example_input in zip(
+        thunder_module_names, original_modules_to_thunder_modules, example_inputs
+    ):
+        subgraph_reports.append(
+            ThunderSplitGraphReport(
+                sub_gm_pair[0],
+                name,
+                sub_gm_pair[1].compiled_fn,
+                example_input,
+                thunder_options,
+                split_reason,
+            )
+        )
+    report = ThunderFXGraphReport(report.graph, report.graph_name, split_reason, subgraph_reports)
+    return report
diff --git a/thunder/dynamo/repro_script_template.py b/thunder/dynamo/repro_script_template.py
index a951149f27..d0c0cee9f4 100644
--- a/thunder/dynamo/repro_script_template.py
+++ b/thunder/dynamo/repro_script_template.py
@@ -113,3 +113,28 @@ def test_{graph_name}(benchmark, executor, compute_type):
         compiled_model = executor(model)
     {call_benchmark}
 '''
+
+
+bsym_torch_compile_repro_template = """
+{python_func}
+
+from thunder.executors.torch_compile import make_compiled as make_torch_compile_callable
+import thunder.examine
+
+inputs = {inputs}
+
+jfn = thunder.jit({func_name})
+jfn(*inputs)
+
+trc = thunder.last_traces(jfn)[-1]
+fusion_symbols = thunder.examine.get_fusion_symbols(trc)
+assert len(fusion_symbols) == 1
+bsym = fusion_symbols[0]
+
+# NOTE: The nvFusion function cannot be compiled directly using `torch.compile`.
+# It must first be processed by Thunder into BoundSymbols and compiled with `make_torch_compile_callable`.
+# Additionally, it's recommended to visually verify that `bsym` matches the
+# `nvFusion` function above by printing it using `print(bsym)`.
+torch_compiled_callable = make_torch_compile_callable(bsym.subsymbols, bsym.flat_args, bsym.flat_outs)
+out = torch_compiled_callable(*inputs)
+"""
diff --git a/thunder/dynamo/utils.py b/thunder/dynamo/utils.py
index 9a0e779da8..8754bca7c4 100644
--- a/thunder/dynamo/utils.py
+++ b/thunder/dynamo/utils.py
@@ -729,3 +729,12 @@ def get_split_reasons_string(subgraph_info: SubgraphInfo) -> str:
     else:
         split_reason_str += "The original graph is not split, and is entirely run by Thunder.\n"
     return split_reason_str
+
+
+def get_thunder_module_names(subgraph_info: SubgraphInfo) -> list[str]:
+    thunder_module_names = []
+    for node in subgraph_info.split_graph_module.graph.nodes:
+        target = node.target
+        if isinstance(target, str) and target.startswith("thunder_"):
+            thunder_module_names.append(target)
+    return thunder_module_names

diff --git a/thunder/tests/test_dynamo.py b/thunder/tests/test_dynamo.py
index cd3f0b5462..9df96dafc5 100644
--- a/thunder/tests/test_dynamo.py
+++ b/thunder/tests/test_dynamo.py
@@ -1072,7 +1072,6 @@ def foo(x):
         assert "Failed to save reproducer" in captured.out

-@pytest.mark.parametrize("use_benchmark", (True, False), ids=("benchmark", "repro"))
 @instantiate(
     dtypes=NOTHING,
     executors=[DynamoThunderExecutor],
@@ -1123,7 +1122,7 @@ def check(file_name, cmd):
     assert len(py_files) == 4

     for file in py_files:
-        check(tmp_path / file, cmd)
+        check(file, cmd)

 def test_leak_on_unsupported_thunder_operator():
@@ -1151,3 +1150,45 @@ def call_thunderfx_on_leaking_fn():

     t_weak_ref = call_thunderfx_on_leaking_fn()
     assert t_weak_ref() is None
+
+
+@requiresCUDA
+def test_thunder_specific_reports(tmp_path):
+    from thunder.dynamo.report import fx_report, analyze_thunder_splits
+
+    x = torch.ones(2, 2, device="cuda", requires_grad=True)
+
+    def foo(x):
+        # torch.sinc has automatic fallback registered,
+        # so that operation will be given to inductor.
+        x = x.exp()
+        torch._dynamo.graph_break()
+        y = torch.sinc(x) + torch.cos(x)
+        return y + 1
+
+    results = fx_report(foo, x)
+    for idx, fx_graph_report in enumerate(results.fx_graph_reports):
+        thunder_fx_graph_report = analyze_thunder_splits(fx_graph_report)
+        thunder_fx_graph_report.write_thunder_repro(tmp_path)
+        for thunder_split_report in thunder_fx_graph_report.subgraph_reports:
+            split_folder = tmp_path / str(idx)
+            thunder_split_report.write_eager_repro(split_folder)
+            thunder_split_report.write_thunder_repro(split_folder)
+            thunder_split_report.write_inductor_repro(split_folder)
+            thunder_split_report.create_fusion_reports()
+            for nvf in thunder_split_report.fusion_reports:
+                nvf.write_nvfuser_repro(split_folder / "nvfusion")
+                nvf.write_inductor_repro(split_folder / "nvfusion")
+                bench_data = nvf.run_benchmark()
+
+    def check(file_name, cmd):
+        cmd = cmd + [file_name]
+        result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+        assert result.returncode == 0, f"Script {file_name} failed: {result}"
+
+    cmd = [sys.executable]
+    py_files = list(tmp_path.rglob("*.py"))
+    assert len(py_files) == 16
+
+    for file in py_files:
+        check(file, cmd)

[Reporting tool] Adds the Thunder segmentation and fusion information to the FXReport
There are two immediate next options I can think of:

One option is to make thunder segmentation and fusion information programmatically available. This could be done by having a function like `analyze_with_thunder` that takes an FXGraphReport and creates a ThunderFXGraphReport, which would be a subclass of FXGraphReport. The ThunderFXGraphReport would have additional ThunderSegmentReport classes, and maybe ThunderFusionReport classes, so that the segments and fusions could be iterated over. Then the ability to write reproducers from the segments alone and from the fusions alone could be added to those classes (reusing the existing functionality as much as possible). That would allow for the creation of files that reproduce or benchmark segments and fusions. Each nvFusion could then be compared to executing with torch.compile instead, for example.

I think the second option is to focus on making the timing data programmatically available at the FX level. Basically running the benchmark and returning its data as a Python object. In the future we should let callers specify their own timers, but a first PR can just pick a timer to use.

Whichever is done first, the second one can probably be done next. The timings don't need to be perfect at first, but it will be exciting to work on the proper timing of fx graphs, segments, and fusions in the near future. @kshitij12345 did some work attempting to compare nvfuser and torch.compile fusions, but the nvFuser team and mixology teams may have some other ideas for timing, too.

Once we have programmatically available timings for fx graphs, segments, and fusions, then I think it's time to revisit running these reports as part of the reports that Mixology produces.

How does that sound, @kiya00?

_Originally posted by @mruberry in https://github.com/Lightning-AI/lightning-thunder/pull/1707#pullrequestreview-2587842277_

cc @crcrpar


diff --git a/docs/nitpick_section.rst b/docs/nitpick_section.rst
index 2d3b8c56..5352e1b2 100644
--- a/docs/nitpick_section.rst
+++ b/docs/nitpick_section.rst
@@ -60,6 +60,24 @@ Multiple keys can be added.
     [nitpick.files.comma_separated_values]
     "setup.cfg" = ["flake8.ignore", "isort.some_key", "another_section.another_key"]

+For config values in INI file that span across multiple lines such as ``flake8.per-file-ignores``, then ``per-file-ignores`` should be added to the list of comma separated values as well as being nested under ``flake8`` section in the style file.
+
+.. code-block:: toml
+
+    [nitpick.files.comma_separated_values]
+    ".flake8" = ["flake8.per-file-ignores"]
+
+    [".flake8".flake8.per-file-ignores]
+    "tests/*.py" = "WPS116,WPS118,WPS2"
+    "tests_2/*.py" = "WPS116,WPS118,WPS218"
+
+.. code-block:: ini
+
+    per-file-ignores =
+      tests/*.py:WPS116,WPS118,WPS2
+      tests_2/*.py:WPS118,WPS218,WPS116
+
+
 [nitpick.styles]
 ----------------

diff --git a/src/nitpick/plugins/ini.py b/src/nitpick/plugins/ini.py
index e1fc86d2..fd94a174 100644
--- a/src/nitpick/plugins/ini.py
+++ b/src/nitpick/plugins/ini.py
@@ -2,6 +2,7 @@

 from __future__ import annotations

+import copy
 from configparser import ConfigParser, DuplicateOptionError, Error, MissingSectionHeaderError, ParsingError
 from io import StringIO
 from typing import TYPE_CHECKING, Any, ClassVar, Iterator
@@ -202,12 +203,94 @@ def enforce_section(self, section: str) -> Iterator[Fuss]:
         for diff_type, key, values in dictdiffer.diff(actual_dict, expected_dict):
             if diff_type == dictdiffer.CHANGE:
                 if f"{section}.{key}" in self.comma_separated_values:
-                    yield from self.enforce_comma_separated_values(section, key, values[0], values[1])
+                    if isinstance(values[1], dict):
+                        # The expected value is a dictionary, so it's a multiline value
+                        # make sure the actual value is also a dictionary
+                        lines = values[0].strip().split("\n")
+                        actual = {
+                            key: value
+                            for line in lines
+                            if not line.startswith((";", "#"))  # ignore INI comments
+                            for key, value in [line.split(":")]
+                        }
+                        expected = values[1]
+                        yield from self.enforce_comma_separated_values_multiline(section, key, actual, expected)
+                    else:
+                        yield from self.enforce_comma_separated_values(section, key, values[0], values[1])
                 else:
                     yield from self.compare_different_keys(section, key, values[0], values[1])
             elif diff_type == dictdiffer.ADD:
                 yield from self.show_missing_keys(section, values)

+    def enforce_comma_separated_values_multiline(
+        self, section, key, raw_actual: dict[str, str], raw_expected: dict[str, str]
+    ) -> Iterator[Fuss]:
+        """Enforce sections and keys with comma-separated values.
+
+        The values might contain spaces.
+        """
+        # This dict is passed around for modifying if autofix is enabled
+        raw_actual_fixed = copy.deepcopy(raw_actual)
+
+        for diff_type, option, values in dictdiffer.diff(raw_actual, raw_expected):
+            if isinstance(option, list):
+                # 'dictdiffer.diff' supports dot notation for nested keys and thus might return a list if the key has a dot '.' in it.
+                # We don't support more than two levels of nested keys in this INI plugin. This check is only precautionary for key that contains '.'
+                option = option[0]  # noqa: PLW2901
+
+            if diff_type == dictdiffer.CHANGE:
+                yield from self._add_missing_values_in_existing_option_of_multiline_config_value(
+                    key, raw_actual_fixed, section, option, values
+                )
+            elif diff_type == dictdiffer.ADD:
+                yield from self._add_missing_option_in_multiline_config_value(key, raw_actual_fixed, section, values)
+
+        if self.autofix:
+            list_of_key_value_pairs = [f"{k}:{v}" for k, v in raw_actual_fixed.items()]
+            self.updater[section][key].set_values(list_of_key_value_pairs, indent=2 * " ")
+            self.dirty = True
+
+    def _add_missing_option_in_multiline_config_value(
+        self, key: str, raw_actual_fixed: dict[str, str], section: str, missing_options: list[tuple[str, str]]
+    ):
+        """Add a missing option in a multiline configuration value."""
+        section_header = "" if section == TOP_SECTION else f"[{section}]\n"
+        for option, value in missing_options:
+            if self.autofix:
+                raw_actual_fixed[option] = value
+            yield self.reporter.make_fuss(
+                Violations.MISSING_OPTION,
+                f'{section_header}{key} = (...)\n"{option}":{value}',
+                key=f'{key}."{option}"',
+                fixed=self.autofix,
+                section=section,
+            )
+
+    def _add_missing_values_in_existing_option_of_multiline_config_value(  # pylint: disable=too-many-arguments
+        self, key: str, raw_actual_fixed: dict[str, str], section: str, option: str, values: tuple[str, str]
+    ):
+        """Add missing values in an existing option of a multiline configuration value."""
+        actual = values[0]
+        expected = values[1]
+
+        actual_set = {s.strip() for s in actual.split(",")}
+        expected_set = {s.strip() for s in expected.split(",")}
+        missing = expected_set - actual_set
+        if not missing:
+            return
+
+        joined_values = ",".join(sorted(missing))
+        value_to_append = f",{joined_values}"
+        if self.autofix:
+            raw_actual_fixed[option] += value_to_append
+        section_header = "" if section == TOP_SECTION else f"[{section}]\n"
+        yield self.reporter.make_fuss(
+            Violations.MISSING_VALUES_IN_LIST,
+            f'{section_header}{key} = (...)\n"{option}":(...){value_to_append}',
+            key=f'{key}."{option}"',
+            fixed=self.autofix,
+        )
+
     def enforce_comma_separated_values(self, section, key, raw_actual: Any, raw_expected: Any) -> Iterator[Fuss]:
         """Enforce sections and keys with comma-separated values.

@@ -270,9 +353,21 @@ def show_missing_keys(self, section: str, values: list[tuple[str, Any]]) -> Iter
         """Show the keys that are not present in a section."""
         parser = ConfigParser()
         missing_dict = dict(values)
-        parser[section] = missing_dict
+
+        # Create a new dictionary to store the updated options
+        updated_missing_dict = {}
+        for key, value in missing_dict.items():
+            if isinstance(value, dict):
+                # Handle multiline config value by converting a dict to a string with the format:
+                # "\n  key1:value1\n  key2:value2"  # noqa: ERA001
+                sep = "\n  "
+                updated_missing_dict[key] = sep + sep.join(f"{k}:{v}" for k, v in value.items())
+            else:
+                updated_missing_dict[key] = value
+
+        parser[section] = updated_missing_dict
         output = self.get_example_cfg(parser)
-        self.add_options_before_space(section, missing_dict)
+        self.add_options_before_space(section, updated_missing_dict)

         if section == TOP_SECTION:
             yield self.reporter.make_fuss(

diff --git a/tests/test_ini.py b/tests/test_ini.py
index f2a11a78..31c84dcc 100644
--- a/tests/test_ini.py
+++ b/tests/test_ini.py
@@ -514,3 +514,178 @@ def test_falsy_values_should_be_reported_and_fixed(tmp_path, datadir):
         ]
     ).assert_file_contents(filename, datadir / "falsy_values/expected.ini")
     project.api_check().assert_violations()
+
+
+def test_comma_separated_values_in_multiline_config_value_should_be_enforced_if_some_lines_have_missing_values(
+    tmp_path,
+):
+    """Target config value is missing some comma-separated-values in some of its lines."""
+    ProjectMock(tmp_path).save_file(
+        ".flake8",
+        """
+        [flake8]
+        per-file-ignores =
+          tests/*.py:WPS116
+          tests_2/*.py:WPS116,WPS118,WPS218
+          tests_3/*.py:WPS218
+        """,
+    ).style(
+        f"""
+        [nitpick.files.{COMMA_SEPARATED_VALUES}]
+        ".flake8" = ["flake8.per-file-ignores"]
+
+        [".flake8".flake8.per-file-ignores]
+        "tests/*.py" = "WPS116,WPS118"
+        "tests_2/*.py" = "WPS116,WPS118,WPS218"
+        "tests_3/*.py" = "WPS116,WPS118,WPS218"
+        """
+    ).api_check_then_fix(
+        Fuss(
+            True,
+            ".flake8",
+            Violations.MISSING_VALUES_IN_LIST.code,
+            " has missing values in the 'per-file-ignores.\"tests/*.py\"' key. Include those values:",
+            '[flake8]\nper-file-ignores = (...)\n"tests/*.py":(...),WPS118',
+        ),
+        Fuss(
+            True,
+            ".flake8",
+            Violations.MISSING_VALUES_IN_LIST.code,
+            " has missing values in the 'per-file-ignores.\"tests_3/*.py\"' key. Include those values:",
+            '[flake8]\nper-file-ignores = (...)\n"tests_3/*.py":(...),WPS116,WPS118',
+        ),
+    ).assert_file_contents(
+        ".flake8",
+        """
+        [flake8]
+        per-file-ignores =
+          tests/*.py:WPS116,WPS118
+          tests_2/*.py:WPS116,WPS118,WPS218
+          tests_3/*.py:WPS218,WPS116,WPS118
+        """,
+    )
+
+
+def test_comma_separated_values_in_multiline_config_value_should_be_enforced_if_some_lines_are_missing(tmp_path):
+    """Target config value exists, but is missing some lines in its multi-line value."""
+    ProjectMock(tmp_path).save_file(
+        ".flake8",
+        """
+        [flake8]
+        per-file-ignores =
+          tests/*.py:WPS116
+        """,
+    ).style(
+        f"""
+        [nitpick.files.{COMMA_SEPARATED_VALUES}]
+        ".flake8" = ["flake8.per-file-ignores"]
+
+        [".flake8".flake8.per-file-ignores]
+        "tests/*.py" = "WPS116"
+        "tests_2/*.py" = "WPS116,WPS118,WPS218"
+        "tests_3/*.py" = "WPS116,WPS118"
+        """
+    ).api_check_then_fix(
+        Fuss(
+            True,
+            ".flake8",
+            Violations.MISSING_OPTION.code,
+            ": section [flake8] has some missing key/value pairs. Use this:",
+            '[flake8]\nper-file-ignores = (...)\n"tests_2/*.py":WPS116,WPS118,WPS218',
+        ),
+        Fuss(
+            True,
+            ".flake8",
+            Violations.MISSING_OPTION.code,
+            ": section [flake8] has some missing key/value pairs. Use this:",
+            '[flake8]\nper-file-ignores = (...)\n"tests_3/*.py":WPS116,WPS118',
+        ),
+    ).assert_file_contents(
+        ".flake8",
+        """
+        [flake8]
+        per-file-ignores =
+          tests/*.py:WPS116
+          tests_2/*.py:WPS116,WPS118,WPS218
+          tests_3/*.py:WPS116,WPS118
+        """,
+    )
+
+
+def test_comma_separated_values_in_multiline_config_value_should_be_enforced_if_missing_entirely(tmp_path):
+    """Target multi-line config value is missing entirely."""
+    ProjectMock(tmp_path).save_file(
+        ".flake8",
+        """
+        [flake8]
+        woof = meow
+        """,
+    ).style(
+        f"""
+        [nitpick.files.{COMMA_SEPARATED_VALUES}]
+        ".flake8" = ["flake8.per-file-ignores"]
+
+        [".flake8".flake8.per-file-ignores]
+        "tests/*.py" = "WPS116,WPS118"
+        "tests_2/*.py" = "WPS116,WPS118,WPS218"
+        """
+    ).api_check_then_fix(
+        Fuss(
+            True,
+            ".flake8",
+            Violations.MISSING_OPTION.code,
+            ": section [flake8] has some missing key/value pairs. Use this:",
+            "[flake8]\n"
+            "per-file-ignores = \n"
+            "\t  tests/*.py:WPS116,WPS118\n"
+            "\t  tests_2/*.py:WPS116,WPS118,WPS218",
+        )
+    ).assert_file_contents(
+        ".flake8",
+        """
+        [flake8]
+        woof = meow
+        per-file-ignores =
+          tests/*.py:WPS116,WPS118
+          tests_2/*.py:WPS116,WPS118,WPS218
+        """,
+    )
+
+
+def test_comma_separated_values_in_multiline_config_value_should_be_without_violation_if_no_changes(tmp_path):
+    """Target multi-line config value has not deviated from the style.
+
+    The comment preceding the 'per-file-ignores' config should be retained.
+    While the comments preceding each option in the 'per-file-ignores' key should be removed.
+    The support to retain such comments after fix is not implemented yet.
+    """
+    ProjectMock(tmp_path).save_file(
+        ".flake8",
+        """
+        [flake8]
+        ; this comment would be retained after fix
+        per-file-ignores =
+            # this comment would be removed after fix
+          tests/*.py:WPS116,WPS118
+          ; another comment that would be removed after fix
+          tests_2/*.py:WPS116,WPS118,WPS218
+        """,
+    ).style(
+        f"""
+        [nitpick.files.{COMMA_SEPARATED_VALUES}]
+        ".flake8" = ["flake8.per-file-ignores"]
+
+        [".flake8".flake8.per-file-ignores]
+        "tests/*.py" = "WPS116,WPS118"
+        "tests_2/*.py" = "WPS116,WPS118,WPS218"
+        """
+    ).api_check_then_fix().assert_file_contents(
+        ".flake8",
+        """
+        [flake8]
+        ; this comment would be retained after fix
+        per-file-ignores =
+          tests/*.py:WPS116,WPS118
+          tests_2/*.py:WPS116,WPS118,WPS218
+        """,
+    )

Support multiline csv value in INI config
- `flake8` config typically has multiline config value such as `per-file-ignores`
- each line has value that is usually comma separated

We should support this type of config for INI format as it's frequently used to incrementally fine-tune flake8 rules