Skip to content

Commit 77b3e8e

Browse files
committed
unix-ffi/re: Cache the compiled patterns.
Every call to re.search(), and to the functions next to it, compiled the pattern it was given. Keep the compiled patterns in a small cache instead, the way CPython does, so that using the same pattern again does not compile it a second time. compile() returns the cached pattern as well, so re.compile(p) is re.compile(p), as it is in CPython. Matching against a repeated pattern gets about twice as fast, compiling one about eight times. Because MicroPython cannot release a compiled pattern by itself, the cache also decides what is kept: a cached pattern stays for the lifetime of the program, and a pattern that this module compiled for its own use is freed again afterwards. The cache owns what it holds and never evicts it. A pattern that is still in use, by the caller or by a call further up the stack, must not be freed underneath it, which a replacement callback passed to sub() can otherwise trigger. The cache is bounded instead: once it is full, further patterns are compiled and, where this module owns them, freed again after use. Signed-off-by: Kirill Lukonin (Evil Wireless Man) <klukonin@gmail.com>
1 parent df9ba13 commit 77b3e8e

2 files changed

Lines changed: 103 additions & 13 deletions

File tree

unix-ffi/re/re.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,18 @@ def span(self, n=0):
9191
class PCREPattern:
9292
def __init__(self, compiled_ptn):
9393
self.obj = compiled_ptn
94+
self.key = None # set while this pattern is held by the cache
9495

9596
def _free(self):
9697
# MicroPython does not run __del__ on instances of Python classes, so
9798
# the compiled pattern cannot be released by the garbage collector and
9899
# has to be freed explicitly.
99100
if self.obj is not None:
101+
if self.key is not None:
102+
# Drop the pattern from the cache first, so that nothing hands
103+
# out a pointer that is about to become invalid.
104+
del _cache[self.key]
105+
self.key = None
100106
pcre2_code_free(self.obj)
101107
self.obj = None
102108

@@ -184,7 +190,7 @@ def findall(self, s):
184190
start = end
185191

186192

187-
def compile(pattern, flags=0):
193+
def _compile(pattern, flags):
188194
# These are output arguments and must be writable and of the size that
189195
# pcre2_compile() writes: int for the error code, PCRE2_SIZE for the offset.
190196
errcode = array.array("i", [0])
@@ -194,48 +200,82 @@ def compile(pattern, flags=0):
194200
return PCREPattern(regex)
195201

196202

197-
# The functions below compile a pattern that is not visible to the caller, so
198-
# they must free it again. The match objects they return do not refer to it.
203+
# Compiled patterns are cached, the way CPython does it, so that using the same
204+
# pattern again does not compile it a second time. compile() returns the
205+
# cached pattern, so re.compile(p) is re.compile(p), as in CPython.
206+
#
207+
# The cache owns the patterns it holds and never evicts them. A pattern that
208+
# is still being used, either by the caller or by a call further up the stack,
209+
# must not be freed underneath it; a replacement callback passed to sub() can
210+
# otherwise trigger exactly that. The cache is bounded instead: once it is
211+
# full, further patterns are compiled and, where this module owns them, freed
212+
# again after use.
213+
_MAXCACHE = 32
214+
_cache = {}
215+
216+
217+
def _cached(pattern, flags):
218+
# Return the compiled pattern, and whether the caller has to free it.
219+
key = (pattern, flags)
220+
r = _cache.get(key)
221+
if r is not None:
222+
return r, False
223+
r = _compile(pattern, flags)
224+
if len(_cache) < _MAXCACHE:
225+
_cache[key] = r
226+
r.key = key
227+
return r, False
228+
return r, True
229+
230+
231+
def compile(pattern, flags=0):
232+
# The pattern belongs to the caller, so it is never freed here.
233+
return _cached(pattern, flags)[0]
199234

200235

201236
def search(pattern, string, flags=0):
202-
r = compile(pattern, flags)
237+
r, owned = _cached(pattern, flags)
203238
try:
204239
return r.search(string)
205240
finally:
206-
r._free()
241+
if owned:
242+
r._free()
207243

208244

209245
def match(pattern, string, flags=0):
210-
r = compile(pattern, flags | PCRE2_ANCHORED)
246+
r, owned = _cached(pattern, flags | PCRE2_ANCHORED)
211247
try:
212248
return r.search(string)
213249
finally:
214-
r._free()
250+
if owned:
251+
r._free()
215252

216253

217254
def sub(pattern, repl, s, count=0, flags=0):
218-
r = compile(pattern, flags)
255+
r, owned = _cached(pattern, flags)
219256
try:
220257
return r.sub(repl, s, count)
221258
finally:
222-
r._free()
259+
if owned:
260+
r._free()
223261

224262

225263
def split(pattern, s, maxsplit=0, flags=0):
226-
r = compile(pattern, flags)
264+
r, owned = _cached(pattern, flags)
227265
try:
228266
return r.split(s, maxsplit)
229267
finally:
230-
r._free()
268+
if owned:
269+
r._free()
231270

232271

233272
def findall(pattern, s, flags=0):
234-
r = compile(pattern, flags)
273+
r, owned = _cached(pattern, flags)
235274
try:
236275
return r.findall(s)
237276
finally:
238-
r._free()
277+
if owned:
278+
r._free()
239279

240280

241281
def escape(s):

unix-ffi/re/test_re_leak.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
# A pattern returned by re.compile() and kept by the caller is not covered
77
# here. MicroPython does not run __del__ on instances of Python classes, so
88
# such a pattern can only be released explicitly.
9+
#
10+
# The bounded cache that the module level functions keep is covered: it must
11+
# not grow past its limit, and the patterns that do not fit into it must be
12+
# freed again.
913

1014
import gc
1115
import re
@@ -21,6 +25,7 @@ def rss():
2125
rss()
2226
except OSError:
2327
# No /proc, so memory use cannot be measured here.
28+
print("SKIP")
2429
raise SystemExit
2530

2631

@@ -97,3 +102,48 @@ def many_groups():
97102

98103

99104
check_no_leak("pattern with several groups", many_groups)
105+
106+
107+
# compile() returns the cached pattern, the way CPython does, so compiling the
108+
# same pattern again does not allocate.
109+
assert re.compile("a(b)c") is re.compile("a(b)c")
110+
check_no_leak("re.compile() with the same pattern", lambda: re.compile("a(b)c"))
111+
112+
# _free() drops the pattern from the cache, so that nothing afterwards hands
113+
# out a pointer to memory that has been released.
114+
r = re.compile("zz(y)")
115+
r._free()
116+
assert re.search("zz(y)", "xxzzyxx").group(0) == "zzy"
117+
118+
119+
# The module level functions cache the patterns they compile. That cache must
120+
# stay bounded, and a pattern that does not fit into it has to be freed again.
121+
counter = [0]
122+
123+
124+
def distinct_patterns():
125+
counter[0] += 1
126+
re.search("a%dc" % counter[0], "xxabcxx")
127+
128+
129+
# Push far more distinct patterns through the cache than it can hold: it has
130+
# to stop growing.
131+
for _ in range(re._MAXCACHE * 4):
132+
distinct_patterns()
133+
assert len(re._cache) <= re._MAXCACHE, len(re._cache)
134+
135+
check_no_leak("re.search() with distinct patterns", distinct_patterns)
136+
assert len(re._cache) <= re._MAXCACHE, len(re._cache)
137+
138+
139+
# A replacement callback runs while sub() is still using its own pattern, and
140+
# may push further patterns through the cache. The pattern that is in use must
141+
# survive that.
142+
def reentrant_repl(m):
143+
counter[0] += 1
144+
re.search("z%dz" % counter[0], "nothing here")
145+
return "z"
146+
147+
148+
check_no_leak("re.sub() with a reentrant callback", lambda: re.sub("a", reentrant_repl, "caaab"))
149+
assert len(re._cache) <= re._MAXCACHE, len(re._cache)

0 commit comments

Comments
 (0)