[Stanford CS144] Lab 0–Lab 3 Lab Notes
The content below was generated entirely by machine translation. Please verify its accuracy. If anything is unclear, consult the Chinese source version.
Note: Because both the lab guide and the course files[1] explicitly state that code must not be published, the lab notes on this blog mainly record ideas and a few core code snippets; I will not publish the complete repository.
Lab 0: Networking Warmup
The lab requires implementing a reliable byte stream in memory (ByteStream), which feels rather similar to a Unix pipe. A first-in, first-out structure like this could be implemented very simply with the STL queue<char>. However, since the lab requires a fixed-capacity byte stream, I personally think it is more appropriate to simulate it directly with an array, which should also be faster.
More specifically, use a string to store the data—I did not use a raw character array because the lab guide recommends modern C++ style and avoiding new for manual memory allocation—along with head and tail pointers that point to the beginning and end of the byte stream. This implements a circular queue. The peek_output() function is roughly:
1 | string ByteStream::peek_output(const size_t len) const { |
Although this implementation looks intuitive, its performance is relatively poor. The main reason is that the circular queue uses the modulo operation extensively, which causes a significant slowdown. Since I had not started Lab 4 at the time, I did not consider performance too deeply. The Lab 0 test results in release mode were:
1 | [100%] Testing Lab 0... |
Lab 1: Stitching Substrings into a Byte Stream
This lab requires implementing a “reassembler”: it rearranges different data fragments into a continuous byte stream according to their supplied starting indices. We must also put received data into the byte stream as quickly as possible. In other words, if every character in has been received, that entire section should be inserted into the byte stream immediately.
Even before considering the experiment itself, the requirements in the lab guide are rather difficult to understand, especially the concept of capacity. In simple terms, it is the size of unread data in the byte stream plus the receive range of the reassembler.
Put another way, the reassembler has limited capacity. If the index of a data segment is too large, the reassembler can discard it directly. The more unread data there is in the byte stream, the smaller the minimum index that will be discarded. This means capacity is shared between bytes already assembled but not yet read and out-of-order bytes still waiting in the reassembler; treating those as two independent capacities would allow the implementation to retain more data than the lab permits.
Implementation
Rough Description
There are many ways to implement the reassembler. The simplest is to copy every arriving data segment, then insert data into the byte stream whenever a continuous run appears at the front of the reassembler.
This algorithm is obviously very inefficient. Every newly arriving data segment must be traversed completely, even if exactly the same data has already been received.
To avoid repeated copying, I implemented a data structure dedicated to maintaining a “set of segments.”
Any newly arriving data segment can be represented as an interval . We can also maintain a set of segments, denoted by , representing the ranges not yet received. For a new segment , if we can calculate the portions where and overlap—that is, —we need only traverse those portions: the unfilled segments covered by . If the length of is 0, the new data contains no previously unreceived portion, so we can return immediately and avoid the repeated traversal described above.
After writing the portion of the new segment, we must also change by removing , indicating that this portion has now been received. The data structure therefore records missing ranges rather than received ranges. A repeated segment has an empty intersection with the missing set and can be rejected without touching every byte again.
Example
The description above may not be very clear, so consider an example.
Suppose the goal is to receive a data segment covering . At the beginning, no data has arrived, so is the range .
Now a new segment arrives. We obtain , meaning that none of the data in is duplicated.
After filling , perform . The minus sign here does not denote a set difference in the formal sense; it means removing a portion from . This indicates that is no longer unfilled. The value of becomes .
Now receive another segment . It completely covers the previous segment , but there is no need to traverse the already filled portion again. Instead, fill only the intersection .
Requirements
At this point, the required data structure is relatively clear. We should implement two classes: Seg, representing one segment, and Segs, representing a set containing many segments.
Segs needs the following operations:
- Find its intersection with a
Seg, the operation described above. - Delete a
Seg, the operation described above.
A Segs can contain many Seg objects. To calculate the intersection of a Segs object and a $Seg object $b$, first find a subset $c$ of $a$ in which every $Seg overlaps , approximately as follows:
1 | 1 2(c1) 3(cn) 4 |
Segments 2 and 3 of overlap and therefore belong to subset .
Algorithm
Traversing the small Seg objects of one by one has linear complexity and is not much better than the naive algorithm.
The optimization I use is binary search.
Let the first segment of subset be —segment 2 in the diagram—and let its last segment be —segment 3 in the diagram.
Observation shows that must be the first segment whose right endpoint is greater than the left endpoint of . Likewise, must be the last segment whose left endpoint is less than the right endpoint of . Expressions of this “maximize a minimum” form can clearly be solved by binary search, provided that the multiple $Segobjects stored inSegs` are ordered.
Because the Segs class frequently inserts and deletes segments, I used std::set<Seg> to store them while maintaining an ordered state for convenient queries.
This reduces the complexity of finding and to . Only the segments between these two iterators can overlap the query, so later intersection and deletion operations can work on the relevant consecutive range rather than scanning the entire set.
The function that queries and is the core of the entire data structure. It is shown below; the other portions are inconvenient to show because of the rule against publishing code.
1 | template <integral T, bool REC_LEN> |
Then, in StreamReassembler::push_substring, the data can be filled directly according to the ranges supplied by Segs:
1 | …… |
The performance of push_substring implemented this way is fairly satisfactory:
1 | [100%] Testing the stream reassembler... |
Later, I also used perf to generate flame graphs and tried to optimize the implementation further. The generated results are below. These SVG images are interactive, but they need to be opened in a separate window.
The first image is from debug mode, and the second is from release mode. In release mode, many functions are inlined, making analysis difficult. In debug mode, however, we can see that operations on Segs consume little time inside push_substring; instead, string operations on the deque are very expensive, such as:
1 | _ZNSt5dequeIcSaIcEEixEm -> std::deque<char, std::allocator<char> >::operator[](unsigned long) |
Clearly, using a deque to store temporary data is not a good choice. However, since Segs performs well, I will leave it unchanged for now and address the string-copying problem specifically when optimizing performance in Lab 4.
Lab 2: The TCP Receiver
This lab has two parts. The first requires implementing conversions between relative and absolute sequence numbers, while the second actually uses the wrapper class implemented earlier to write the TCP receiver.
Completing this lab requires a basic understanding of the TCP header. First, one message may be split into many small segments for transmission under TCP, and every segment has a header. The SYN and FIN flags mark the beginning and end of the transmission, respectively.
That is, if SYN in a header is true, the TCP packet is the first packet of the entire message. FIN similarly identifies the last packet.
We normally use 0 as the first index in a sequence of data, such as a character array. TCP does not do this: the index of the first data is randomized. Every TCP header contains a sequence number, seqno, that denotes the starting index of the data in that packet. A packet containing SYN is the first packet in the whole sequence, so its seqno is the first index of the whole sequence. We call this first index the ISN, or initial sequence number.
Why use a random sequence number? The main reason is to avoid confusion with historical data. During an earlier connection, some packets may have been transmitted extremely slowly because of network congestion and may arrive only after the connection has closed. If sequence numbers were not randomized, the historical packet’s sequence number would very likely fall inside the receive window of the new connection and be accepted incorrectly[2].
Sequence-Number Wrapper
Although the TCP packet’s index is randomized, when we use it—for example, in the previously implemented push_substring function—we still need to convert it into a zero-based index. This index differs from seqno and is 64 bits wide.
The lab guide calls the zero-based index the absolute sequence number, or absolute seqno. We need to write a class specifically to convert between these two kinds of sequence number.
Converting an absolute sequence number to a wrapped seqno is simple: return ISN plus the absolute sequence number. Natural overflow directly produces the wrapped sequence number.
Converting a wrapped seqno back to an absolute sequence number is not as simple. A wrapped seqno is 32 bits, while the absolute sequence number is 64 bits, so the same wrapped value can correspond to multiple absolute values separated by multiples of . The required $unwrapfunction therefore receives an additionalcheckpoint`; the converted absolute sequence number must be the one closest to that checkpoint. Without the checkpoint, the low 32 bits alone cannot tell us which wraparound period contains the intended absolute position.
The problem is clearer in mathematical language. Let the checkpoint be , the wrapped sequence number be , and .
We need to find an absolute sequence number such that while minimizing .
My implementation is below. It may look confusing at first glance; in fact, the explanation below is also rather confusing. I tried several ways to express the idea, but my mathematics and writing abilities prevented me from explaining it clearly.
1 | //! \param n The relative sequence number |
Here, offset is the distance, modulo , from $checkpoint + isnto theseqno` being converted. It may be positive or negative.
1 | 0 2^32 2*2^32 3*2^32 |
To obtain a seqno closest to checkpoint + isn, add the newly obtained offset to checkpoint + isn. This is equivalent to adding some multiple of to the $seqno`.
Subtracting isn from offset + checkpoint + isn yields the absolute sequence number, because a wrapped and absolute sequence number differ by exactly isn.
Therefore, the absolute sequence number equals offset + checkpoint.
However, this direct calculation may not produce the optimal solution. The following is the result of using the method directly:
1 | 0 2^32 2*2^32 3*2^32 |
We can see that adding directly to the current $seqnowould place it closer tocheckpoint + isn`, while still satisfying .
Such a failure to find the optimum can only occur when .
Adding any multiple of to $seqno` does not change it modulo . The offset does change, however, and our goal is to minimize that offset.
For example, if , which certainly satisfies the preceding inequality, then:
As in the preceding example, adding directly to $seqno` gives:
1 | 2^32 2*2^32 3*2^32 4*2^32 |
Natural overflow handles this problem for us, so we do not need to deal with it ourselves.
Notice that offset is stored as an int32_t. It is signed and has exactly the range .
Therefore, whenever , offset automatically adds or subtracts a multiple of from itself to minimize its value.
This implementation still has a bug, however. Consider:
1 | 0 2^32 |
The offset here is clearly positive and greater than . Subtracting from $seqnowould reduce the absolute value of the offset, but it would also makeseqno` negative, which is clearly invalid. The following lines prevent a negative result: if the result is negative, add back.
1 | static constexpr uint32_t MX32 = numeric_limits<uint32_t>::max(); |




