Skip to content

F11. Two tables on the side - #129

Merged
tamnd merged 1 commit into
mainfrom
f11-two-tables-on-the-side
Aug 31, 2026
Merged

F11. Two tables on the side#129
tamnd merged 1 commit into
mainfrom
f11-two-tables-on-the-side

Conversation

@tamnd

@tamnd tamnd commented Aug 31, 2026

Copy link
Copy Markdown
Owner

The twenty fifth lesson, and the eleventh of the front end part. F10 opened the code object and named co_linetable and co_exceptiontable without saying what is in them. This one decodes both.

The angle is that the two tables are the same idea twice. A traceback needs to know which line and which columns an instruction came from. A raise needs to know which handler covers an offset. Neither is needed while a program is going right, so neither is allowed to cost anything while it is. Both got moved out of the instruction stream into a compressed blob that nothing reads until something has already gone wrong.

What the lesson does

It opens with the zero cost claim and then checks it rather than asserting it. Wrap a loop in a try, take the instructions inside the covered range, and compare them against the same loop with no try at all. Same list. SETUP_FINALLY and POP_BLOCK still exist in the compiler, and F08 watched the optimizer move them around, but the assembler deletes both and writes a table instead, so neither reaches the bytecode.

Then it decodes an exception table entry byte by byte. Four numbers, all in code units, six bits to a byte, and the top bit of the first byte marking where an entry starts. That marker is the interesting part: it is what lets a raise binary search a table whose entries are not all the same length, because you can land anywhere and walk backwards to a boundary. Twelve lines of Python decode the whole thing and the cell checks its answer against dis._parse_exception_table.

The second half is the location table. Four numbers per instruction, six entry forms, and the assembler picks the smallest one that fits. The lesson rebuilds a traceback's caret from the column numbers by hand, then writes a thirty line decoder and runs it against 299 code objects out of argparse, dataclasses, dis and json.decoder. Zero disagreements with co_positions().

It closes on a detail I did not expect. The two tables use the same six bit varint and put the chunks in opposite orders. parse_varint reads most significant first, write_varint writes least significant first, and they are forty lines apart in the same header. It is not sloppiness. The exception table is binary searched on the first number of each entry, and reading the big end first is what makes that comparison cheap.

One thing that took a while

My location decoder disagreed with co_positions() at exactly one instruction, and the internal documentation did not explain why. write_location_info_long_form in Python/assemble.c writes loc.col_offset + 1 rather than loc.col_offset, so that a stored zero can mean "no column here", and advance_with_locations takes the one back off on the way out. Neither InternalDocs/code_objects.md nor the format comment mentions it. The lesson says so, because it is a decent reminder that the source is the specification.

Checks

Every code cell has a varies note, because all seven of them print offsets or byte counts and 3.14 compiles these functions a little smaller than 3.15. The prose holds on both, and the two lines that have to be exact, dis agrees on every one of them and 0 of them decoded differently, say the same thing on both.

Six diagrams, .excalidraw and .svg, all under 1100 px. The lesson runs end to end in Pyodide. Both READMEs have their rows. just check and just versions are green locally.

Part of M3, issue #22.

The line table and the exception table, both decoded by hand and both checked
against the interpreter's own decoders.

The angle is that the two are the same idea twice. A traceback needs to know
which line and which columns an instruction came from, and a raise needs to
know which handler covers an offset, and neither is needed while a program is
going right. So both live beside the bytecode rather than in it, and nothing
reads either until something has already gone wrong.

The exception table decoder agrees with dis._parse_exception_table. The
location table decoder agrees with co_positions() on 299 code objects out of
four standard library modules. The zero cost claim is checked rather than
asserted: the instructions inside a try are the same list as the same loop
with no try around it, and SETUP_FINALLY and POP_BLOCK never reach the
bytecode.

One detail worth the trip: the two tables encode their integers with the six
bit chunks in opposite orders, and the reason is that the exception table gets
binary searched on its first number.

Six diagrams, seven cells with version notes, and the lesson runs end to end
in Pyodide.
@tamnd tamnd added kind/lesson A chapter: prose, notebook, experiments, boss fight area/frontend Tokenizer, PEG parser, AST, symbol table, codegen, code objects labels Aug 31, 2026
@tamnd tamnd mentioned this pull request Aug 31, 2026
5 tasks
@tamnd
tamnd merged commit b2a1f23 into main Aug 31, 2026
16 checks passed
@tamnd
tamnd deleted the f11-two-tables-on-the-side branch August 31, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/frontend Tokenizer, PEG parser, AST, symbol table, codegen, code objects kind/lesson A chapter: prose, notebook, experiments, boss fight

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant