CROFS Design Doc
김도근 2022-12171
1. On-disk layout
#superblock
The superblock sits on block 1 and holds the fields the grader needs: magic
(0x43524653, which is "CRFS" in ascii), size (total image size in 1KB blocks), nblocks
(number of data blocks), ninodes, inodestart (block 2), datastart (which is 2 + the
number of inode blocks), and plainsize (the estimated uncompressed size in blocks,
used as the baseline for the ratio). Since CROFS is read only theres no log or free
bitmap like in normal xv6, so the image is just the boot block, then the superblock,
then the inode blocks, then the data blocks. At mount crofs_init checks the magic
number and also the basic geometry, inodestart >= 2, datastart > inodestart, size >=
datastart and nblocks == size - datastart. If any of those dont hold it just panics
instead of trusting a broken image.
#inode format
I kept the normal 64 byte dinode. The first 5 fields (type, major, minor, nlink, size)
are left alone so the existing inode code and fstat still work without me touching
them. Directories and the /console device use the normal uncompressed block pointer
layout. For a regular file I reuse the addrs array, where addrs[0] points to the files
compressed extent (the index + chunks, explained below).
#compressed file data and index
Each regular file is stored as one contiguous extent of whole disk blocks, and
addrs[0] is its first block. Inside the extent its the index first and then the
chunks. The index has one crofs_ent per logical block and each entry stores 3 things.
eoff is the byte offset of that blocks chunk inside the extent, elen is the chunk
length, and flag says whether its compressed or stored raw. Each entry is 8 bytes. The
chunks come right after the index, packed back to back. A chunk is either the LZSS
compressed block, or if compressing it didnt actually make it any smaller its just
kept raw (flag = stored). The block count isnt stored anywhere, its just the file size
divided by the unit size rounded up, which I already get from the inode.
#how size is computed
A regular files size is the original uncompressed byte length. That way read, lseek,
fstat and exec all see the real size no matter how the bytes are actually laid out on
disk. The root directorys size is rounded up to a whole number of blocks. plainsize
adds up, over every file and directory, how many blocks a normal xv6 inode would need
(bytes / 1024 rounded up, plus one indirect block if it goes past 12 blocks). Thats
basically the size of the same image if it wasnt compressed, and its the bottom of the
compression ratio.
2. Compression design
#algorithm
I went with LZSS. Each unit gets compressed on its own. The encoder looks for repeated
byte sequences inside the unit using hash chains and a one step lazy match, and spits
out a mix of literals and matches, where a match is a (distance, length) pair. The
tokens are grouped in 8s with a control byte in front, one bit per token saying
literal or match. A distance can be up to 4096 (12 bits) and a length is 3 to 273 (a 4
bit base plus an extra escape byte for the long ones). If a unit doesnt get smaller
its kept raw. Decoding (crofs_decompress) just replays the tokens. I picked LZSS
because it gets a good ratio on both text and binaries while the decoder stays really
small and fast.
#compression unit
The unit is 4KB (CROFS_UNIT), which is seperate from the 1KB disk block. A bigger unit
gives the matcher more context so the ratio goes up, its around 2.29 with a 1KB unit
and 2.58 at 4KB. Each unit compresses by itself so any single unit can be decoded
without the others. The downside is a small 1KB read now lands inside a 4KB unit,
which is mitigated via the partial decode below.
#finding the chunk on a random read
The index makes this basically O(1). Block i's entry is at extent byte i *
sizeof(crofs_ent), and that gives me the chunk offset and length straight away. So a
read jumps right to the chunk it needs, no scanning, and it never has to decode from
the start of the file.
#ratio vs decode time
The bigger the unit is the ration will be better but the decode time will be worse. A
bigger unit helps the ratio but makes a small read decode more, since a 1KB read of a
4KB unit would unfortunatly have to decode all 4KB. So I added partial decode.
crofs_readi_comp passes the number of bytes it actually needs (offset + length inside
the unit) down to the decoder through aux, and crofs_decompress stops once it hits
that limit. This is fine because LZSS matches only ever point backwards to bytes thats
already decoded, so the first part of the decode is the same whether you finish the
rest or not.
3. Kernel integration
#lseek
sys_lseek takes fd, offset and whence. It picks a base off whence, 0 for SEEK_SET, the
current f->off for SEEK_CUR, or the inode size (read under ilock) for SEEK_END, then
the new offset is base + offset. It only works on regular files and returns -1 for
pipes/devices. A negative result returns -1, but seeking past the end is allowed . I
do the sum in a 64 bit long so an int offset added onto a uint size cant overflow.
#reading a compressed file
crofs_readi_file looks at the inode type. Directories go through crofs_readi_plain
which is just the normal block pointer walk, and regular files go through
crofs_readi_comp. For each logical block the read touches, crofs_readi_comp reads that
blocks index entry, reads the chunk with crofs_pread (which can span two consecutive
extent blocks since a chunk can cross a block boundary), decodes it through the
decompress wrapper (or uses it as is if its stored raw), then copies out the slice
that was asked for. The decode goes through the wrapper in fshook.c on purpose so the
grading stats actually get counted.
#exec
exec didnt need any special code. To load a program it reads the elf header, program
headers and segments through readi -> crofs_readi_file, and since that hands back the
right bytes at any offset (decompressing as it goes) the binaries just load and run
like normal. ls, echo, cat etc all running is the proof. crofs_readi_file is basically
the read only half of the normal readi, the size clamping is already done by the readi
wrapper in fshook.c and the block lookup uses my read only crofs_bmap instead of the
allocating bmap.
#memory usage
A compressed read grabs two scratch pages with kalloc, one to hold the chunk and one
for the decoded 4KB unit, and frees both before it returns. Nothing decoded is kept
between reads, the only cache is xv6's normal buffer cache holding the compressed disk
blocks. So the extra memory is just 2 pages per read in flight.
4. Testing and validation
#test cases
I booted xv6 on the image and ran the basics: ls lists dir, cat and wc read files
right, and echo runs so exec works. For lseek I checked SEEK_END gives the file size,
seeking then reading gives the bytes at that offset, and a negative result returns -1.
For the read only side, write intent opens return -1 while /console can still be
opened for writing, and echo > x, mkdir and rm all fail without crashing. For
decompression I checked an FNV checksum of a text file (README) and a binary (ls) read
through the kernel matches the host checksum, so its byte perfect.
#corner cases
The big one is read only enforcement. A write has to fail at open, not at the write,
so opening a regular file with O_WRONLY/O_RDWR/O_TRUNC/O_CREATE returns -1 while
/console stays writable. link, unlink, mkdir and mknod all return -1, and
writei/itrunc are turned off so nothing reaches the disabled allocator or panics.
#most time consuming part
Getting the read only behavior right took the most tries. Just making writei return -1
isnt enough since open(O_WRONLY) still succeeds, and the grader wants the open itself
to fail, so the rejection had to go in sys_open. The other slow part was tuning, where
I measured the score against ratio vs decode cost and landed on 4KB units plus partial
decode.