Repository navigation
gh-159207: Store Tachyon samples in bounded columnar blocks - #160313
ivonastojanovic wants to merge 2 commits into
Conversation
|
cc @maurycy |
Tachyon currently stores all samples in a single compressed stream. Replay has to decompress the complete sample section before it can process any samples, so temporary memory usage grows with the length of the capture. The stream also mixes data with different patterns and repeatedly stores values such as thread and interpreter IDs. Change the file format to split samples into independently decodable blocks, each limited to 1 MiB of decoded data. Strings and frames remain shared across the whole file, preserving deduplication across the complete capture. Within each block, separate the sample data into columns for stream identifiers, timestamps, statuses, stack changes, and frame indices. Store each thread and interpreter pair once and have samples refer to those pairs by index. Encode timestamps relative to the expected sampling time for each stream, and store status bits common to all samples in the block once while packing the bits that vary into bitmaps. Encode stacks by reusing unchanged stacks, retaining common outer frames when only part of a stack changes, and referring back to complete stacks already seen in the block. Store frame indices using the smallest byte width needed by the block. These encodings remove repeated values and group similar data together before compression. Compress each column independently as raw bytes, a repeated byte, or zstd, whichever is smallest. The block header contains the information needed to locate, validate, and decode these columns independently. Replay can then decode one block at a time and reuse its buffers for subsequent blocks. This bounds temporary sample decompression memory by the block size rather than the total capture length, although the shared string and frame tables still grow with the number of unique entries. This change only applies to profiles exported to files. Live streaming will need a different approach because it cannot rely on string and frame tables written at the end of the file, and is left for pythongh-145464.
71c18bd to
8865260
Compare
|
I did some benchmarking while working on this, so leaving the results here for reference. For the main comparison, I profiled
I also experimented with compressing the file-wide string and frame tables, so I included those numbers in the last column. It helps a bit more with zstd, but I left it out of this PR since the change is already pretty large. We can always add that separately. I also tried two smaller programs with zstd to see how the format behaves with different workloads. One does JSON and zlib work across eight threads, so repeated stacks are interleaved across several streams. The other runs SQLite queries and processes the results in Python, so it mostly stays on one stream and moves between a small set of stacks.
The small differences between the two block-format results are just normal run-to-run variation. These are also bytes per sample. I also checked a few synthetic stack patterns with zstd: one stable stack, stacks where only the inner frames change, complete stacks shared across threads, and a bank of randomly selected stacks.
The random stack case is the obvious regression. The frame IDs do not compress particularly well, and repeated stacks can end up in different blocks, so we lose the ability to reuse them across block boundaries. The main thing I wanted to improve with this change was replay memory, though. With the previous format, peak memory grew from 31.0 MiB at 100,000 samples to 59.4 MiB at one million samples. With the block reader, it stayed at around 26.3 MiB. |
Tachyon's binary format currently stores all samples in a single compressed stream. Each record contains its thread and interpreter IDs, timestamp, status, and stack change, so values such as the thread and interpreter IDs are written repeatedly across records. The existing RLE encoding avoids some of this when a stack repeats, and compression reduces it further, but the format still mixes fields with very different patterns in the same stream.
More importantly, this means replay has to decompress the complete sample stream before it can process the first sample. The temporary buffer therefore grows with the length of the capture, even though replay only needs a small part of the sample data at a time.
This PR changes the binary format to split samples into bounded, independently decodable blocks. Replay can decompress and process one block at a time, then reuse the same buffers for the next block. Within each block, sample fields are also separated into columns, allowing repeated values to be removed before compression and each type of data to use an encoding that better matches its structure.
This change focuses on profiles exported to regular files. Live streaming remains separate work in #145464. Regular files can keep shared string and frame tables at the end of the file, while a live stream cannot refer to tables that have not been sent yet. For streaming, we may instead need to include the required string and frame data in each block so that it can be decoded as soon as it arrives.
Implementation
The new file layout is:
Each sample block has its own header followed by six columns:
Samples keep their original order across these columns, but no longer need a complete record containing every field. Values shared across a block move into its header or tables, while the per-sample columns only store what changes. The block header contains the sample count, base timestamp, frame-ID width, status masks, and the size and encoding of each column.
(thread_id, interpreter_id)pair used by the block, onceThe writer first reserves space for the file header. It then collects samples into a block and starts a new one when the decoded column data reaches 1 MiB or the block reaches 4,096 unique
(thread_id, interpreter_id)pairs. Once sampling finishes, it writes the shared string and frame tables, statistics, and footer, then seeks back to fill in the file header.Strings and frames remain shared across the whole file, preserving deduplication across the complete capture. These tables still grow with the number of unique strings and frames, but temporary decoded sample data is now bounded by the size of one block rather than the total number of samples.
Timestamp and stack state are tracked independently for each stream. Timestamps are stored as residuals from the expected timestamp, so samples arriving at the normal interval usually store zero. Status bits shared by all samples in a block are stored once in the block header, while only varying bits need per-sample bitmaps.
Stacks use three instructions:
REPEATcountcountsamplesREPLACE_SUFFIXkeep_count,new_countnew_countframe IDsREUSEoffset,countREPEATis tracked per stream, so samples from other streams can appear between repetitions. The other instructions either write only the frames that changed or reuse a complete stack already stored in the block. Frame IDs use between one and four bytes and are stored in byte planes to improve compression.Each column is stored as raw bytes, a single repeated byte, or zstd level 3, whichever is smallest. The file-wide string and frame tables similarly choose between raw and zstd.
After discussing this with @pablogsal, we decided not to keep support for version 1 profiles since this change will be shipped in 3.16.
The public
BinaryWriter,BinaryReader, and collector APIs remain unchanged, except that theget_stats()dictionaries now reportreuse_recordsinstead ofpop_push_records, since the pop-push encoding no longer exists.