I'm relatively new to Python 3, especially IO programming, writing a Blender add-on to import model data.
The model data is available in a custom compression, and I originally wrote code in C# to decompress it in memory, porting it to Python 3.
However, being a little unsure about the "optimal" usage of IO classes and functions in Python, I got a little speed problem. The code runs 10 times slower in Python compared to the C# equivalent, and I don't find any more optimization potential, because of my limited Python knowledge.
A test yielded the following speed results at decompressing the same file (around 50 megabytes of data):
- C#: ~4-5 seconds
- Python: ~43 seconds
I wonder if anyone can point out where I'm failing hard in Python and need to learn a lot...
Here is the C# code. It uses an extended System.IO.BinaryReader
and System.IO.BinaryWriter
, operating the same; just with handling endianness more flexible.
public static int Decompress(Stream input, MemoryStream output)
{
using (BinaryDataReader reader = new BinaryDataReader(input, true))
using (BinaryDataWriter writer = new BinaryDataWriter(output, true))
{
reader.ByteOrder = ByteOrder.BigEndian;
uint decompressedSize = reader.ReadUInt32();
// Decompress the data.
int decompressedBytes = 0;
while (decompressedBytes < decompressedSize)
{
// Read the configuration byte of a decompression setting group, and go through each bit of it.
byte groupConfig = reader.ReadByte();
for (int i = 7; i >= 0; i--)
{
// Check if bit of the current chunk is set.
if ((groupConfig & (1 << i)) == (1 << i))
{
// Bit is set, copy 1 raw byte to the output.
writer.Write(reader.ReadByte());
decompressedBytes++;
}
else if (decompressedBytes < decompressedSize) // This does not make sense for last byte.
{
// Bit is not set and data copying configuration follows, either 2 or 3 bytes long.
ushort dataBackSeekOffset = reader.ReadUInt16();
int dataSize;
// If the nibble of the first back seek offset byte is 0, the config is 3 bytes long.
byte nibble = (byte)(dataBackSeekOffset >> 12/*1 byte (8 bits) + 1 nibble (4 bits)*/);
if (nibble == 0)
{
// Nibble is 0, the number of bytes to read is in third byte, which is (size + 0x12).
dataSize = reader.ReadByte() + 0x12;
}
else
{
// Nibble is not 0, and determines (size + 0x02) of bytes to read.
dataSize = nibble + 0x02;
// Remaining bits are the real back seek offset.
dataBackSeekOffset &= 0x0FFF;
}
// Since bytes can be reread right after they were written, write and read bytes one by one.
for (int j = 0; j < dataSize; j++)
{
// Read one byte from the current back seek position.
writer.Position -= dataBackSeekOffset + 1;
byte readByte = (byte)writer.BaseStream.ReadByte();
// Write the byte to the end of the memory stream.
writer.Seek(0, SeekOrigin.End);
writer.Write(readByte);
decompressedBytes++;
}
}
}
}
return decompressedBytes;
}
}
I translated it very closely into Python, using struct
to read non-byte data from the input stream, otherwise just using io.BytesIO
for the in-memory decompression:
def decompress(compressed):
decompressed_size = struct.unpack(">I", compressed.read(4))[0]
# Use an in-memory stream and open a reader/writer on it to decompress in.
decompressed = io.BytesIO()
# Decompress the data.
decompressed_bytes = 0
while decompressed_bytes < decompressed_size:
# Read the configuration byte of a decompression setting group, and go through each bit of it.
group_config = compressed.read(1)[0]
for i in range(7, -1, -1):
# Check if the bit of the current chunk is set.
if group_config & (1 << i) == 1 << i:
# Bit is set, copy 1 raw byte to the output.
decompressed.write(compressed.read(1))
decompressed_bytes += 1
elif decompressed_bytes < decompressed_size: # This does not make sense for the last byte.
# Bit is not set and data copying configuration follows, either 2 or 3 bytes long.
data_back_seek_offset = struct.unpack(">H", compressed.read(2))[0]
# If the nibble of the first back seek offset byte is 0, the config is 3 bytes long.
nibble = data_back_seek_offset >> 12 # 1 byte (8 bits) + 1 nibble (4 bits)
if nibble:
# Nibble is not 0, and determines (size + 0x02) of bytes to read.
data_size = nibble + 0x02
# Remaining bits are the real back seek offset
data_back_seek_offset &= 0x0FFF
else:
# Nibble is 0, the number of bytes to read is in third byte, which is (size + 0x12).
data_size = compressed.read(1)[0] + 0x12
# Since bytes can be re-read right after they were written, write and read bytes one by one.
for j in range(0, data_size):
# Read one byte from the current back seek position.
decompressed.seek(-data_back_seek_offset - 1, io.SEEK_CUR)
read_byte = decompressed.read(1)
# Write the byte to the end of the memory stream.
decompressed.seek(0, io.SEEK_END)
decompressed.write(read_byte)
decompressed_bytes += 1
# Seek back to the start of the in-memory stream and return it.
decompressed.seek(0)
return decompressed
I hope this question is not too broad and doesn't require too much work... some general optimization tips when using BytesIO
(or using a class suiting this case better?) would be great already!
A test file for decompression can be found here (~42 MB). Python needs ~22 seconds to decompress it, while C# only requires ~2 seconds. When decompressed successfully, it results in a 4000x4000 bitmap looking like this: