Skip to content

Commit 26059c0

Browse files
authored
Merge pull request #20 from gf712/runtime-improvements
2 parents 4e55dbe + e840895 commit 26059c0

37 files changed

Lines changed: 802 additions & 616 deletions

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ include(CheckCXXSourceCompiles)
55

66
project(python++)
77

8-
set(CMAKE_CXX_STANDARD 20)
8+
set(CMAKE_CXX_STANDARD 23)
99

1010
include(cmake/CPM.cmake)
1111

integration/run_python_tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
66

77
PYTHON_EXECUTABLE=$1
8-
GC_FREQUENCY=100000
8+
GC_FREQUENCY=${GC_FREQUENCY:-100000}
99

1010
# start by calling the tests that we need to work in order to trust the result of the other python tests
1111
if timeout 10s $PYTHON_EXECUTABLE $SCRIPT_DIR/tests/lemmas/assert_false.py --gc-frequency $GC_FREQUENCY &> /dev/null; then

integration/tests/dict.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,26 @@ def dict_setdefault():
4545
assert a["b"] == 10
4646

4747
dict_setdefault()
48+
49+
def dict_pop_missing_key_with_failing_repr():
50+
# Regression: PyDict::pop formatted KeyError via key.__repr__() and
51+
# unconditionally unwrapped the result, aborting if __repr__ raised.
52+
class Bad:
53+
def __hash__(self):
54+
return 0
55+
def __eq__(self, other):
56+
return False
57+
def __repr__(self):
58+
raise ValueError("bad repr")
59+
60+
d = {}
61+
raised = None
62+
try:
63+
d.pop(Bad())
64+
except KeyError:
65+
raised = "KeyError"
66+
except ValueError:
67+
raised = "ValueError"
68+
assert raised is not None, "dict.pop on missing key with failing __repr__ must not abort"
69+
70+
dict_pop_missing_key_with_failing_repr()

integration/tests/list.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,13 @@
2323
exception_raised = True
2424
finally:
2525
assert exception_raised, "list.pop with empty list should raise an IndexError"
26+
27+
def list_recursive_repr():
28+
a = []
29+
a.append(a)
30+
assert repr(a) == "[[...]]", "recursive list repr should use [...] sentinel"
31+
# Calling repr again must drain the visited set; otherwise nested
32+
# repr() would still see `a` as visited.
33+
assert repr(a) == "[[...]]", "recursive list repr should be idempotent"
34+
35+
list_recursive_repr()

integration/tests/weakref.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import _weakref
2+
import gc
3+
4+
# A class that supports weakrefs (built-ins like int/list typically don't).
5+
class Foo:
6+
pass
7+
8+
def weakref_registers_and_counts():
9+
f = Foo()
10+
r = _weakref.ref(f)
11+
# Holding a weakref does not bump the strong-ref count, but the runtime
12+
# must record the registration so weakref_count is observable.
13+
assert _weakref.getweakrefcount(f) == 1
14+
# The weakref still resolves to the target while the target is alive.
15+
assert r() is f
16+
17+
weakref_registers_and_counts()
18+
19+
def weakref_resolves_through_gc_collect():
20+
# gc.collect() forces a full mark/sweep regardless of cadence/pause
21+
# state, so this scope can deterministically check that the weakref
22+
# keeps tracking the target across collection cycles.
23+
f = Foo()
24+
r = _weakref.ref(f)
25+
gc.collect()
26+
# `f` is still on the stack here, so it must survive the collection.
27+
assert r() is f
28+
assert _weakref.getweakrefcount(f) == 1
29+
30+
weakref_resolves_through_gc_collect()
31+
32+
def gc_collect_does_not_crash_when_disabled():
33+
# Disabling the GC must not prevent gc.collect() from running; this
34+
# mirrors CPython's gc.collect() semantics.
35+
gc.disable()
36+
try:
37+
assert gc.isenabled() is False
38+
gc.collect()
39+
finally:
40+
gc.enable()
41+
assert gc.isenabled() is True
42+
43+
gc_collect_does_not_crash_when_disabled()
44+
45+
def weakref_wrapper_unregisters_on_its_own_collection():
46+
# Regression test for B8. Whenever a weakref wrapper is collected,
47+
# the runtime must scrub the heap's m_weakrefs table — otherwise
48+
# the per-target vector accumulates dangling pointers and
49+
# getweakrefcount lies. Create N immediately-unreachable wrappers
50+
# and confirm the count is zero after gc.collect(). Pre-fix this
51+
# interpreter reported 100/100 survived; post-fix and on CPython
52+
# (which refcounts wrappers away on the spot) it reports 0/100.
53+
N = 100
54+
def churn(target):
55+
for _ in range(N):
56+
_weakref.ref(target) # result discarded
57+
t = Foo()
58+
churn(t)
59+
gc.collect()
60+
remaining = _weakref.getweakrefcount(t)
61+
assert remaining == 0, \
62+
f"expected all wrappers collected, {remaining}/{N} survived"
63+
64+
weakref_wrapper_unregisters_on_its_own_collection()

src/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ set(RUNTIME_SOURCE_FILES
144144
runtime/modules/struct/module.cpp
145145
runtime/modules/SysModule.cpp
146146
runtime/modules/WarningsModule.cpp
147+
runtime/modules/GcModule.cpp
147148
runtime/types/builtin.cpp
148149
runtime/warnings/DeprecationWarning.cpp
149150
runtime/warnings/ImportWarning.cpp

src/executable/bytecode/Bytecode.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,14 @@ PyResult<Value> Bytecode::call_without_setup(VirtualMachine &vm, Interpreter &in
106106
// create main stack frame
107107
ASSERT(!vm.stack().empty());
108108

109-
constexpr auto sentinel = decltype(vm.stack().top().get().last_instruction_pointer)();
110-
if (vm.stack().top().get().last_instruction_pointer == sentinel) {
109+
constexpr auto sentinel = decltype(vm.stack().back().get().last_instruction_pointer)();
110+
if (vm.stack().back().get().last_instruction_pointer == sentinel) {
111111
// first time calling with the stack frame, so we don't have a last instruction pointer yet
112112
vm.set_instruction_pointer(begin());
113113
} else {
114114
// otherwise resume execution, by starting execution from the instruction after the last run
115115
// instruction
116-
vm.set_instruction_pointer(vm.stack().top().get().last_instruction_pointer + 1);
116+
vm.set_instruction_pointer(vm.stack().back().get().last_instruction_pointer + 1);
117117
}
118118

119119
return eval_loop(vm, interpreter);

src/interpreter/Interpreter.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ PyModule *Interpreter::get_imported_module(PyString *name) const
269269
ScopedStack::~ScopedStack()
270270
{
271271
auto &vm = VirtualMachine::the();
272-
if (!vm.stack().empty() && top_frame && &vm.stack().top().get() == top_frame.get()) {
272+
if (!vm.stack().empty() && top_frame && &vm.stack().back().get() == top_frame.get()) {
273273
vm.pop_frame(true);
274274
}
275275
}

src/memory/GarbageCollector.cpp

Lines changed: 66 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -59,27 +59,38 @@ __attribute__((no_sanitize_address)) std::stack<Cell *> collect_roots_on_the_sta
5959
{
6060
std::stack<Cell *> roots;
6161

62-
// push register values onto the stack
62+
// Spill all callee-saved registers into `jump_buffer`. setjmp captures
63+
// them as part of saving the calling environment.
6364
std::jmp_buf jump_buffer;
6465
setjmp(jump_buffer);
6566

66-
// traverse the stack from the stack pointer to the stack origin/bottom
67-
// uintptr_t *rsp_;
68-
// asm volatile("movq %%rsp, %0" : "=r"(rsp_));
67+
auto scan_range = [&](uint8_t *begin, uint8_t *end) {
68+
for (uint8_t *p = begin; p < end; p += sizeof(uintptr_t)) {
69+
uint8_t *address =
70+
bit_cast_without_sanitizer<uint8_t *>(*bit_cast_without_sanitizer<uintptr_t *>(p))
71+
- sizeof(GarbageCollected);
72+
spdlog::trace("checking address {}, pointer address={}", (void *)address, (void *)p);
73+
if (heap.slab().has_address(address)) {
74+
spdlog::trace("valid address {}", (void *)address);
75+
auto *obj_header = bit_cast<GarbageCollected *>(address);
76+
add_root(obj_header, roots);
77+
}
78+
}
79+
};
6980

70-
// uint8_t *rsp = bit_cast<uint8_t *>(rsp_);
81+
// jump_buffer is a local variable, so it sits below the current frame
82+
// pointer on stacks that grow downward. The frame-pointer-to-stack-bottom
83+
// scan below therefore skips it, which would lose any GC root whose only
84+
// live reference is in a callee-saved register at this point. Scan the
85+
// buffer's bytes explicitly to recover those spilled values.
86+
auto *jb_begin = bit_cast_without_sanitizer<uint8_t *>(&jump_buffer);
87+
scan_range(jb_begin, jb_begin + sizeof(jump_buffer));
88+
89+
// Traverse the stack from the current frame pointer up to the recorded
90+
// stack bottom; this covers everything in our callers' frames.
7191
uint8_t *rsp = bit_cast_without_sanitizer<uint8_t *>(__builtin_frame_address(0));
72-
for (; rsp < stack_bottom; rsp += sizeof(uintptr_t)) {
73-
uint8_t *address =
74-
bit_cast_without_sanitizer<uint8_t *>(*bit_cast_without_sanitizer<uintptr_t *>(rsp))
75-
- sizeof(GarbageCollected);
76-
spdlog::trace("checking address {}, pointer address={}", (void *)address, (void *)rsp);
77-
if (heap.slab().has_address(address)) {
78-
spdlog::trace("valid address {}", (void *)address);
79-
auto *obj_header = bit_cast<GarbageCollected *>(address);
80-
add_root(obj_header, roots);
81-
}
82-
}
92+
scan_range(rsp, stack_bottom);
93+
8394
spdlog::debug("Done collecting roots from the stack, found {} roots", roots.size());
8495
return roots;
8596
}
@@ -149,20 +160,27 @@ struct MarkGCVisitor : Cell::Visitor
149160
Heap &m_heap;
150161
std::stack<Cell *> &m_to_visit;
151162

163+
// Collects the 1-hop neighbours of a given cell. visit_graph(visitor) on
164+
// the root invokes Visitor::visit() for each thing the root references
165+
// (which, by convention, may include the root itself for leaf cells); the
166+
// `m_collecting` gate isolates those nested calls so only direct
167+
// neighbours land in the vector and we do not recurse further.
152168
struct NeighbourVisitor : Cell::Visitor
153169
{
154170
std::vector<Cell *> m_neighbours;
155-
size_t m_depth{ 0 };
171+
bool m_collecting{ false };
156172

157-
void visit(Cell &cell)
173+
void collect_neighbours_of(Cell &root)
158174
{
159-
m_depth++;
160-
if (m_depth == 1) {
161-
cell.visit_graph(*this);
162-
} else if (m_depth == 2) {
163-
m_neighbours.push_back(&cell);
164-
}
165-
m_depth--;
175+
m_neighbours.clear();
176+
m_collecting = true;
177+
root.visit_graph(*this);
178+
m_collecting = false;
179+
}
180+
181+
void visit(Cell &cell) override
182+
{
183+
if (m_collecting) { m_neighbours.push_back(&cell); }
166184
}
167185
};
168186

@@ -174,7 +192,7 @@ struct MarkGCVisitor : Cell::Visitor
174192

175193
if (!is_static_memory(cell_start, m_heap)) {
176194
NeighbourVisitor nv{};
177-
nv.visit(cell);
195+
nv.collect_neighbours_of(cell);
178196
auto &neighbours = nv.m_neighbours;
179197
spdlog::trace("node: {}", static_cast<void *>(&cell));
180198
for (auto *neighbour : neighbours) {
@@ -212,8 +230,15 @@ struct MarkGCVisitor : Cell::Visitor
212230

213231
void MarkSweepGC::mark_all_cell_unreachable(Heap &heap) const
214232
{
215-
// TODO: once the ideal block sizes are fixed there should be an iterator
216-
// returning a list of all blocks
233+
// NOTE: this site intentionally does NOT use Slab::for_each_block. Doing
234+
// so produces a stack layout (lambda captures across inlining) that
235+
// keeps a Block pointer alive in a slot the next collect_roots scan
236+
// reads as a heap root, regressing
237+
// TestHeap.GarbageCollectorDeallocatesGCPointersWhenStackFrameIsPopped.
238+
// The conservative-GC fragility this exposes is the underlying issue;
239+
// until it is replaced by a precise RootSet, keep the explicit array
240+
// here so the test stays green. The other sweep/has_address sites are
241+
// fine because they run after the scan, not before.
217242
std::array blocks = {
218243
std::reference_wrapper{ heap.slab().block_16() },
219244
std::reference_wrapper{ heap.slab().block_32() },
@@ -262,21 +287,8 @@ void MarkSweepGC::sweep(Heap &heap) const
262287
{
263288
spdlog::trace("MarkSweepGC::sweep start");
264289

265-
// TODO: once the ideal block sizes are fixed there should be an iterator
266-
// returning a list of all blocks
267-
std::array blocks = {
268-
std::reference_wrapper{ heap.slab().block_16() },
269-
std::reference_wrapper{ heap.slab().block_32() },
270-
std::reference_wrapper{ heap.slab().block_64() },
271-
std::reference_wrapper{ heap.slab().block_128() },
272-
std::reference_wrapper{ heap.slab().block_256() },
273-
std::reference_wrapper{ heap.slab().block_512() },
274-
std::reference_wrapper{ heap.slab().block_1024() },
275-
std::reference_wrapper{ heap.slab().block_2048() },
276-
};
277-
// sweep all the dead objects
278-
for (const auto &block : blocks) {
279-
for (auto &chunk : block.get()->chunks()) {
290+
heap.slab().for_each_block([this, &heap](Block &block) {
291+
for (auto &chunk : block.chunks()) {
280292
chunk.for_each_cell_alive([this, &chunk, &heap](uint8_t *memory) {
281293
auto *header = bit_cast<GarbageCollected *>(memory);
282294
if (header->white()) {
@@ -293,27 +305,29 @@ void MarkSweepGC::sweep(Heap &heap) const
293305
}
294306
});
295307
}
296-
}
308+
});
297309
spdlog::trace("MarkSweepGC::sweep done");
298310
}
299311

300312

301-
void MarkSweepGC::run(Heap &heap) const
313+
void MarkSweepGC::mark_and_sweep(Heap &heap)
302314
{
303-
if (m_pause) { return; }
304-
if (++m_iterations_since_last_sweep < m_frequency) { return; }
305-
306315
mark_all_cell_unreachable(heap);
307-
308316
auto roots = collect_roots(heap);
309-
310317
mark_all_live_objects(heap, std::move(roots));
311-
312318
sweep(heap);
313-
314319
m_iterations_since_last_sweep = 0;
315320
}
316321

322+
void MarkSweepGC::run(Heap &heap)
323+
{
324+
if (m_pause) { return; }
325+
if (++m_iterations_since_last_sweep < m_frequency) { return; }
326+
mark_and_sweep(heap);
327+
}
328+
329+
void MarkSweepGC::force_run(Heap &heap) { mark_and_sweep(heap); }
330+
317331
void MarkSweepGC::resume()
318332
{
319333
ASSERT(!is_active());

src/memory/GarbageCollector.hpp

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,15 @@ class GarbageCollector
6464
{
6565
public:
6666
virtual ~GarbageCollector() = default;
67-
virtual void run(Heap &) const = 0;
67+
// Not const: a GC pass advances the collector's per-call cadence
68+
// counter, flips every cell's mark colour, and may destroy a large
69+
// chunk of the heap. Pretending the GC instance is immutable across
70+
// the call is a const-correctness lie that this signature avoids.
71+
virtual void run(Heap &) = 0;
72+
// Unconditional collection. Bypasses both the pause flag and the
73+
// cadence counter; used by gc.collect() so user code can force a
74+
// full sweep even while the GC has been disabled.
75+
virtual void force_run(Heap &) = 0;
6876
virtual void resume() = 0;
6977
virtual void pause() = 0;
7078
virtual bool is_active() const = 0;
@@ -81,7 +89,8 @@ class MarkSweepGC : public GarbageCollector
8189
{
8290
public:
8391
MarkSweepGC();
84-
void run(Heap &) const override;
92+
void run(Heap &) override;
93+
void force_run(Heap &) override;
8594
void resume() override;
8695
void pause() override;
8796
bool is_active() const override;
@@ -92,7 +101,11 @@ class MarkSweepGC : public GarbageCollector
92101
void sweep(Heap &heap) const;
93102

94103
private:
104+
void mark_and_sweep(Heap &);
105+
106+
// Lazily initialised once on first collect_roots; afterwards immutable.
107+
// `mutable` is the textbook one-time-cache use case.
95108
mutable uint8_t *m_stack_bottom{ nullptr };
96-
mutable size_t m_iterations_since_last_sweep{ 0 };
109+
size_t m_iterations_since_last_sweep{ 0 };
97110
bool m_pause{ false };
98111
};

0 commit comments

Comments
 (0)