-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add deflate implemented from first principles #18923
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent work!
Your proposed API and naming conventions are spot-on. Let's proceed immediately.
Thanks also to @squeek502 for helping fuzz test this code.
.@"tar.gz" => try unpackTarballCompressed(f, tmp_directory.handle, resource, std.compress.gzip), | ||
.@"tar.gz" => { | ||
const reader = resource.reader(); | ||
var br = std.io.bufferedReaderSize(std.crypto.tls.max_ciphertext_record_len, reader); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does your implementation actually require an additional buffered reader? This was here because the previous implementation did many small reads (1-8 bytes). If your implementation calls read() with a buffer size closer to 16K (the value of max_ciphertext_record_len
) then this is not necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When I first test fetching without buffered reader it failed with EndOfStream so I just put it because it was in previous implementation. Now I check why that is necessary and found bug in reading input stream in decompression. I was using read instead of readAll and getting less bytes then available in the stream. Now when fixed buffered reader is not needed any more.
Decompression is doing many small reads, as in previous implementation. Internal bit reader has buffer of 8 bytes. Decompressor has output buffer of 64K (it needs history data of at least 32K) but there is no input buffer it only gets as many bytes as needed to fill 8 byte bit buffer.
Compressor is other way around. It has 64K input buffer, reads in chunks of 32K and small output buffer of 248 bytes.
Currently I didn't remove buffered reader reasoning that behavior is same as previous.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now I check why that is necessary and found bug in reading input stream in decompression. I was using read instead of readAll and getting less bytes then available in the stream. Now when fixed buffered reader is not needed any more.
Note that calling read() instead of readAll() is appropriate for implementation of a stream. This will block on the underlying read() call only once, allowing users of the stream to possibly do other things with the thread while waiting for more data to be available to read.
However, make sure to notice correctly when the end of stream occurs. A short read does not mean the end of stream.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After writing the above comment, I looked at the commit you were referring to, and I see that in this case the amount read is very small (8 bytes or less). In this case it is OK to use readAll.
A potentially better solution would be to still use only 1 call to read(), and have an internal small buffer of those 8 bytes, and then only emit the bytes from read when the buffer is filled, however, this will require an updated definition of std.io.Reader.read because currently it says if the number of bytes read is 0 it means end of stream.
lib/std/compress.zig
Outdated
// Version 1 interface | ||
pub const v1 = struct { | ||
pub const deflate = @import("compress/deflate.zig"); | ||
pub const gzip = @import("compress/gzip.zig"); | ||
pub const zlib = @import("compress/zlib.zig"); | ||
}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was very considerate of you to do, however, please go ahead and delete the old code. At this point in time in the zig project, it's the best path forward.
I cannot wait until I don't have to see "Go non-regression test..." flash in front of my eyes every time I run the std lib tests 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exactly what I was expecting from you ;-)
@jacobly0 would you prefer @ianic to work with you on resolving the x86 backend crashes found by this branch (example below), or disable the corresponding tests? example:
|
I've fixed the autodoc related CI failure in a23ab33 |
Zig deflate compression/decompression implementation. It supports compression and decompression of gzip, zlib and raw deflate format. Fixes ziglang#18062. This PR replaces current compress/gzip and compress/zlib packages. Deflate package is renamed to flate. Flate is common name for deflate/inflate where deflate is compression and inflate decompression. There are breaking change. Methods signatures are changed because of removal of the allocator, and I also unified API for all three namespaces (flate, gzip, zlib). Currently I put old packages under v1 namespace they are still available as compress/v1/gzip, compress/v1/zlib, compress/v1/deflate. Idea is to give users of the current API little time to postpone analyzing what they had to change. Although that rises question when it is safe to remove that v1 namespace. Here is current API in the compress package: ```Zig // deflate fn compressor(allocator, writer, options) !Compressor(@typeof(writer)) fn Compressor(comptime WriterType) type fn decompressor(allocator, reader, null) !Decompressor(@typeof(reader)) fn Decompressor(comptime ReaderType: type) type // gzip fn compress(allocator, writer, options) !Compress(@typeof(writer)) fn Compress(comptime WriterType: type) type fn decompress(allocator, reader) !Decompress(@typeof(reader)) fn Decompress(comptime ReaderType: type) type // zlib fn compressStream(allocator, writer, options) !CompressStream(@typeof(writer)) fn CompressStream(comptime WriterType: type) type fn decompressStream(allocator, reader) !DecompressStream(@typeof(reader)) fn DecompressStream(comptime ReaderType: type) type // xz fn decompress(allocator: Allocator, reader: anytype) !Decompress(@typeof(reader)) fn Decompress(comptime ReaderType: type) type // lzma fn decompress(allocator, reader) !Decompress(@typeof(reader)) fn Decompress(comptime ReaderType: type) type // lzma2 fn decompress(allocator, reader, writer !void // zstandard: fn DecompressStream(ReaderType, options) type fn decompressStream(allocator, reader) DecompressStream(@typeof(reader), .{}) struct decompress ``` The proposed naming convention: - Compressor/Decompressor for functions which return type, like Reader/Writer/GeneralPurposeAllocator - compressor/compressor for functions which are initializers for that type, like reader/writer/allocator - compress/decompress for one shot operations, accepts reader/writer pair, like read/write/alloc ```Zig /// Compress from reader and write compressed data to the writer. fn compress(reader: anytype, writer: anytype, options: Options) !void /// Create Compressor which outputs the writer. fn compressor(writer: anytype, options: Options) !Compressor(@typeof(writer)) /// Compressor type fn Compressor(comptime WriterType: type) type /// Decompress from reader and write plain data to the writer. fn decompress(reader: anytype, writer: anytype) !void /// Create Decompressor which reads from reader. fn decompressor(reader: anytype) Decompressor(@typeof(reader) /// Decompressor type fn Decompressor(comptime ReaderType: type) type ``` Comparing this implementation with the one we currently have in Zig's standard library (std). Std is roughly 1.2-1.4 times slower in decompression, and 1.1-1.2 times slower in compression. Compressed sizes are pretty much same in both cases. More resutls in [this](https://github.com/ianic/flate) repo. This library uses static allocations for all structures, doesn't require allocator. That makes sense especially for deflate where all structures, internal buffers are allocated to the full size. Little less for inflate where we std version uses less memory by not preallocating to theoretical max size array which are usually not fully used. For deflate this library allocates 395K while std 779K. For inflate this library allocates 74.5K while std around 36K. Inflate difference is because we here use 64K history instead of 32K in std. If merged existing usage of compress gzip/zlib/deflate need some changes. Here is example with necessary changes in comments: ```Zig const std = @import("std"); // To get this file: // wget -nc -O war_and_peace.txt https://www.gutenberg.org/ebooks/2600.txt.utf-8 const data = @embedfile("war_and_peace.txt"); pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer std.debug.assert(gpa.deinit() == .ok); const allocator = gpa.allocator(); try oldDeflate(allocator); try new(std.compress.flate, allocator); try oldZlib(allocator); try new(std.compress.zlib, allocator); try oldGzip(allocator); try new(std.compress.gzip, allocator); } pub fn new(comptime pkg: type, allocator: std.mem.Allocator) !void { var buf = std.ArrayList(u8).init(allocator); defer buf.deinit(); // Compressor var cmp = try pkg.compressor(buf.writer(), .{}); _ = try cmp.write(data); try cmp.finish(); var fbs = std.io.fixedBufferStream(buf.items); // Decompressor var dcp = pkg.decompressor(fbs.reader()); const plain = try dcp.reader().readAllAlloc(allocator, std.math.maxInt(usize)); defer allocator.free(plain); try std.testing.expectEqualSlices(u8, data, plain); } pub fn oldDeflate(allocator: std.mem.Allocator) !void { const deflate = std.compress.v1.deflate; // Compressor var buf = std.ArrayList(u8).init(allocator); defer buf.deinit(); // Remove allocator // Rename deflate -> flate var cmp = try deflate.compressor(allocator, buf.writer(), .{}); _ = try cmp.write(data); try cmp.close(); // Rename to finish cmp.deinit(); // Remove // Decompressor var fbs = std.io.fixedBufferStream(buf.items); // Remove allocator and last param // Rename deflate -> flate // Remove try var dcp = try deflate.decompressor(allocator, fbs.reader(), null); defer dcp.deinit(); // Remove const plain = try dcp.reader().readAllAlloc(allocator, std.math.maxInt(usize)); defer allocator.free(plain); try std.testing.expectEqualSlices(u8, data, plain); } pub fn oldZlib(allocator: std.mem.Allocator) !void { const zlib = std.compress.v1.zlib; var buf = std.ArrayList(u8).init(allocator); defer buf.deinit(); // Compressor // Rename compressStream => compressor // Remove allocator var cmp = try zlib.compressStream(allocator, buf.writer(), .{}); _ = try cmp.write(data); try cmp.finish(); cmp.deinit(); // Remove var fbs = std.io.fixedBufferStream(buf.items); // Decompressor // decompressStream => decompressor // Remove allocator // Remove try var dcp = try zlib.decompressStream(allocator, fbs.reader()); defer dcp.deinit(); // Remove const plain = try dcp.reader().readAllAlloc(allocator, std.math.maxInt(usize)); defer allocator.free(plain); try std.testing.expectEqualSlices(u8, data, plain); } pub fn oldGzip(allocator: std.mem.Allocator) !void { const gzip = std.compress.v1.gzip; var buf = std.ArrayList(u8).init(allocator); defer buf.deinit(); // Compressor // Rename compress => compressor // Remove allocator var cmp = try gzip.compress(allocator, buf.writer(), .{}); _ = try cmp.write(data); try cmp.close(); // Rename to finisho cmp.deinit(); // Remove var fbs = std.io.fixedBufferStream(buf.items); // Decompressor // Rename decompress => decompressor // Remove allocator // Remove try var dcp = try gzip.decompress(allocator, fbs.reader()); defer dcp.deinit(); // Remove const plain = try dcp.reader().readAllAlloc(allocator, std.math.maxInt(usize)); defer allocator.free(plain); try std.testing.expectEqualSlices(u8, data, plain); } ```
Testing on windows is failing because line ending are changed on some binary files during git checkout.
By using read instead of readAll decompression reader could get bytes then available in the stream and then later wrongly failed with end of stream.
I didn't understand the difference. ref: https://ziglang.org/documentation/0.11.0/#Comments
It was usefull during development. From andrewrk code review comment: In fact, Zig does not guarantee the @sizeof structs, and so these tests are not valid.
Before removal of v1.
Are you sure that is not a bug? Perhaps running the same test in Valgrind would turn up a similar issue for x86_64-linux. |
Indeed it does, so I would not consider those disabled tests to be a merge blocker. However, it would be nice to link to the github issue next to where they are being skipped. |
I don't understand the problem here. test "flate.wasm" {
const D = struct {
lookup: Lookup = .{},
win: SlidingWindow = .{},
tokens: Tokens = .{},
level: LevelArgs,
const Self = @This();
pub fn init() !Self {
return Self{
.level = LevelArgs.get(.default),
};
}
};
_ = try D.init();
} Now anything I touch makes it pass.
And the failure is:
|
Ah I wonder if it is stack overflow. Perhaps this option could be illuminating:
|
Running wasmtime with various options didn't make the difference. But I got all test passing by raising stack to 8MB from default 1MB (#12589)
|
Ah nice find. I think this is a good reason to bump the default. I see no reason for WASI to not use the same default as the other operating systems. |
Thanks @andrewrk for merging this and all the guys here who helped, especially to @squeek502 who found many bugs and did great work with fuzz testing. |
Great work on this @ianic, thanks for being so quick with the fixes on all the stuff found by fuzzing! By the way, I'm planning on running the fuzzers continuously for a few days just to have more confidence everything's good, but I'll be away from my normal setup for a little while. Will follow up once I run the fuzzers again. |
I left the roundtrip fuzzer going while I was away and it's found some stuff (but need to confirm they're real bugs; it was using the code before ianic/flate@902ee48) EDIT: They were all fixed after updating to the latest implementation 😄, still interested in the answer to this question though: @ianic where would you like me to report any bugs found? The |
If I can choose then Zig issue tracker, because anyway it will result in PR. |
91 hours of continuous fuzzing later, nothing new found. That's good enough for me 👍 |
… implementation from ziglang/zig#18923
Zig deflate compression/decompression implementation. It supports compression and decompression of gzip, zlib and raw deflate format.
Fixes #18062.
This PR replaces current compress/gzip and compress/zlib packages. Deflate package is renamed to flate. Flate is common name for deflate/inflate where deflate is compression and inflate decompression.
There are breaking change. Methods signatures are changed because of removal of the allocator, and I also unified API for all three namespaces (flate, gzip, zlib).
Currently I put old packages under v1 namespace they are still available as compress/v1/gzip, compress/v1/zlib, compress/v1/deflate. Idea is to give users of the current API little time to postpone analyzing what they had to change. Although that rises question when it is safe to remove that v1 namespace.
API
Here is current API in the compress package:
The proposed naming convention:
Benchmark
Comparing this implementation with the one we currently have in Zig's standard library (std). Std is roughly 1.2-1.4 times slower in decompression, and 1.1-1.2 times slower in compression. Compressed sizes are pretty much same in both cases. More resutls in this repo.
Memory usage
This library uses static allocations for all structures, doesn't require allocator. That makes sense especially for deflate where all structures, internal buffers are allocated to the full size. Little less for inflate where we std version uses less memory by not preallocating to theoretical max size array which are usually not fully used.
For deflate this library allocates 395K while std 779K. For inflate this library allocates 74.5K while std around 36K.
Inflate difference is because we here use 64K history instead of 32K in std.
Upgrade
If merged existing usage of compress gzip/zlib/deflate need some changes. Here is example with necessary changes in comments: