Repository navigation
LP reader: reduce memory use and speed up reading small files - #3446
Conversation
Lexer::fill grew its buffer by kChunkSize (1 MB) on each refill and again on each look-ahead past the end of the file; it now reads 16 KB first, doubles up to kChunkSize, and stops refilling at the end of the file. zstr::ifstream is opened with 64 KB buffers instead of two of 1 MB, and renderDiagnostics no longer reopens the file when there is nothing to render. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks! I'll take a look. I'll admit that I didn't benchmark small files. I was mainly focused on the performance of reading very big files. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## latest #3446 +/- ##
==========================================
+ Coverage 75.11% 75.19% +0.07%
==========================================
Files 460 460
Lines 112060 112317 +257
Branches 17769 17804 +35
==========================================
+ Hits 84178 84456 +278
+ Misses 27600 27573 -27
- Partials 282 288 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks @odow, your changes fix the issues I identified. If you're after speed, two small changes in the lexer help:
Together they gave about 10% on large files in my tests, and more on mid-sized ones, with peak memory unchanged. Let me know if you'd like me to code that up. |
|
This is a nice but small gain on Claude tells me the difference might be more in linux because macOS uses a different buffer strategy for |
|
Thanks @odow for coding it up. I measured on Linux with GCC’s libstdc++, where std::deque frees and reallocates a 512-byte block about every 10 tokens: on a 3.8 MB file the ring buffer removes about 90,000 allocations and 24% of instructions (~10% wall time; ~25% at 350 KB). Built against libc++ the deque reuses its blocks, so the gain drops to about 10% on small-to-mid files and is within noise on large ones, which matches your 5%. So the change is mostly a Linux/libstdc++ win. |
|
Thanks @odow. I was making sure the ring buffer holds up on bad input, so I fed the reader some corrupted and binary input (zip archives, damaged gzip/zlib streams, random bytes). The ring buffer was fine, but I found three other cases that fail on the current head (
diff --git a/check/TestLpFileFormat.cpp b/check/TestLpFileFormat.cpp
index 6486ac3..953ba7a 100644
--- a/check/TestLpFileFormat.cpp
+++ b/check/TestLpFileFormat.cpp
@@ -1857,3 +1857,66 @@ TEST_CASE("lp-file-format-just-over-1mib", "[LpFileFormat]") {
REQUIRE(sum_lower == double(num_row) * (num_row - 1) / 2);
REQUIRE(lp.row_names_.back() == "c" + std::to_string(num_row - 1));
}
+
+TEST_CASE("lp-file-format-corrupt-gzip", "[LpFileFormat]") {
+ // A gzip header followed by a deflate block of the reserved type. zstr
+ // detects the gzip header from the content, not the file extension, and
+ // throws when it decompresses the block; readModel must return an error
+ // rather than let the exception escape.
+ const std::string test_name = Catch::getResultCapture().getCurrentTestName();
+ std::string filename = test_name + ".lp";
+ const std::string content("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x07", 11);
+ Highs highs;
+ std::string log;
+ REQUIRE(readLp(highs, filename, content, log) == HighsStatus::kError);
+#ifdef ZLIB_FOUND
+ REQUIRE(log.find("invalid block type") != std::string::npos);
+#endif
+}
+
+TEST_CASE("lp-file-format-long-token-of-utf8-continuation-bytes",
+ "[LpFileFormat]") {
+ // Quoting a token longer than 30 bytes backs up to the start of a UTF-8
+ // character. A token made only of continuation bytes has no such start, so
+ // this must stop at the beginning of the token rather than read before it.
+ const std::string test_name = Catch::getResultCapture().getCurrentTestName();
+ std::string filename = test_name + ".lp";
+ const std::string content = std::string(40, '\x80') + "\n";
+ Highs highs;
+ std::string log;
+ REQUIRE(readLp(highs, filename, content, log) == HighsStatus::kError);
+ REQUIRE(log.find("expected a section keyword") != std::string::npos);
+}
+
+TEST_CASE("lp-file-format-corrupt-gzip-after-parse-error", "[LpFileFormat]") {
+ // A parse error at the start of a long line, in a gzip file whose checksum
+ // is wrong. The parser stops before the end of the stream, but rendering
+ // the error reads the rest of the line, which reaches the bad checksum.
+ // The tokens are short, so the parser stops reading early. The data is in
+ // stored (uncompressed) deflate blocks of 60000 bytes.
+ const std::string test_name = Catch::getResultCapture().getCurrentTestName();
+ std::string filename = test_name + ".lp";
+ std::string content("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03", 10);
+ const size_t kBlock = 60000;
+ const size_t kNumBlock = 4;
+ for (size_t b = 0; |
|
I've fixed the first and second ones. it looks like you copied the third test only partially. |
|
Thanks for all your wonderful fixes @odow.
diff --git a/check/TestLpFileFormat.cpp b/check/TestLpFileFormat.cpp
index 6486ac3..953ba7a 100644
--- a/check/TestLpFileFormat.cpp
+++ b/check/TestLpFileFormat.cpp
@@ -1857,3 +1857,66 @@ TEST_CASE("lp-file-format-just-over-1mib", "[LpFileFormat]") {
REQUIRE(sum_lower == double(num_row) * (num_row - 1) / 2);
REQUIRE(lp.row_names_.back() == "c" + std::to_string(num_row - 1));
}
+
+TEST_CASE("lp-file-format-corrupt-gzip", "[LpFileFormat]") {
+ // A gzip header followed by a deflate block of the reserved type. zstr
+ // detects the gzip header from the content, not the file extension, and
+ // throws when it decompresses the block; readModel must return an error
+ // rather than let the exception escape.
+ const std::string test_name = Catch::getResultCapture().getCurrentTestName();
+ std::string filename = test_name + ".lp";
+ const std::string content("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x07", 11);
+ Highs highs;
+ std::string log;
+ REQUIRE(readLp(highs, filename, content, log) == HighsStatus::kError);
+#ifdef ZLIB_FOUND
+ REQUIRE(log.find("invalid block type") != std::string::npos);
+#endif
+}
+
+TEST_CASE("lp-file-format-long-token-of-utf8-continuation-bytes",
+ "[LpFileFormat]") {
+ // Quoting a token longer than 30 bytes backs up to the start of a UTF-8
+ // character. A token made only of continuation bytes has no such start, so
+ // this must stop at the beginning of the token rather than read before it.
+ const std::string test_name = Catch::getResultCapture().getCurrentTestName();
+ std::string filename = test_name + ".lp";
+ const std::string content = std::string(40, '\x80') + "\n";
+ Highs highs;
+ std::string log;
+ REQUIRE(readLp(highs, filename, content, log) == HighsStatus::kError);
+ REQUIRE(log.find("expected a section keyword") != std::string::npos);
+}
+
+TEST_CASE("lp-file-format-corrupt-gzip-after-parse-error", "[LpFileFormat]") {
+ // A parse error at the start of a long line, in a gzip file whose checksum
+ // is wrong. The parser stops before the end of the stream, but rendering
+ // the error reads the rest of the line, which reaches the bad checksum.
+ // The tokens are short, so the parser stops reading early. The data is in
+ // stored (uncompressed) deflate blocks of 60000 bytes.
+ const std::string test_name = Catch::getResultCapture().getCurrentTestName();
+ std::string filename = test_name + ".lp";
+ std::string content("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03", 10);
+ const size_t kBlock = 60000;
+ const size_t kNumBlock = 4;
+ for (size_t b = 0; b < kNumBlock; b++) {
+ std::string data;
+ while (data.size() < kBlock) data += "x ";
+ if (b == 0) data.replace(0, 4, "bad ");
+ if (b + 1 == kNumBlock) data.back() = '\n';
+ content += char(b + 1 == kNumBlock ? 1 : 0); // BFINAL, BTYPE = stored
+ content += char(kBlock & 0xFF);
+ content += char(kBlock >> 8);
+ content += char(~kBlock & 0xFF);
+ content += char((~kBlock >> 8) & 0xFF);
+ content += data;
+ }
+ content += std::string(8, '\0'); // wrong CRC32 and length
+ Highs highs;
+ std::string log;
+ REQUIRE(readLp(highs, filename, content, log) == HighsStatus::kError);
+#ifdef ZLIB_FOUND
+ REQUIRE(log.find("expected a section keyword, found `bad`") !=
+ std::string::npos);
+#endif
+}
|
Reading a small LP file allocates about 7 MB (peak about 5 MB) during
Highs::readModel, whatever its size. This matters where the heap does not shrink, e.g. WebAssembly, or when many small models are read. Causes, all inhighs/io/LpReader.cpp:Lexer::fillgrows its buffer bykChunkSize(1 MB) and zero-fills it on each refill, and again on each look-ahead past the end of the file.zstr::ifstreamis opened with its default buffers, two of 1 MB, also for plain files.renderDiagnosticsreopens the file even when there are no warnings.Changes:
fillreads 16 KB first and doubles up tokChunkSize, and stops refilling once it has reached the end of the file. zstr is opened with 64 KB buffers.renderDiagnosticsreturns early when there is nothing to render.(Linux, clang, Release. Heap counted by replacing
operator new. Read time is the median per read over 3 rounds. Gzipped files behave the same.)Testing:
ctest169/169,unit_tests441/441. Old and new readers give identical messages, status and model on 364 files: 300 random files, plus edge cases such as empty and truncated files, CRLF, no final newline, parse errors, warnings, a 2 MB line, sizes of 1 byte either side of each buffer boundary, and gzipped files. The result is the same with-DLP_READER_CHUNK_SIZE=7 -D_GLIBCXX_ASSERTIONS.@odow, since you wrote the new reader: does this fit what you had in mind?
Checklist
latestbranch