File: strings.rst

package info (click to toggle)
libgnatcoll 18-4
  • links: PTS, VCS
  • area: main
  • in suites: buster
  • size: 5,068 kB
  • sloc: ada: 40,393; python: 354; ansic: 310; makefile: 245; sh: 31
file content (267 lines) | stat: -rw-r--r-- 12,438 bytes parent folder | download | duplicates (6)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
.. highlight:: ada

*************************************
**Strings**: high-performance strings
*************************************

The generic package :file:`GNATCOLL.Strings_Impl` (and its default
instantiation in :file:`GNATCOLL.Strings`) provides a high-performance
strings implementation.

It comes in addition to Ada's own `String` and `Unbounded_String` types,
although it attempts to find a middle ground in between (flexibility
vs performance).

GNATCOLL.Strings therefore provides strings (named `XString`, as in
extended-strings) that can grow as needed (up to `Natural'Last`, like standard
strings), yet are faster than unbounded strings. They also come with an
extended API, which includes all primitive operations from unbounded strings,
in addition to some subprograms inspired from GNATCOLL.Utils and the python and
C++ programming languages.

Small string optimization
=========================

GNATCOLL.Strings uses a number of tricks to improve on the efficiency.  The
most important one is to limit the number of memory allocations.  For this, we
use a trick similar to what all C++ implementations do nowadays, namely the
small string optimization.

The idea is that when a string is short, we can avoid all memory allocations
altogether, while still keeping the string type itself small. We therefore
use an Unchecked_Union, where a string can be viewed in two ways::

    Small string

      [f][s][ characters of the string 23 bytes               ]
         f  = 1 bit for a flag, set to 0 for a small string
         s  = 7 bits for the size of the string (i.e. number of significant
              characters in the array)

    Big string

      [f][c      ][size     ][data      ][first     ][pad    ]
         f = 1 bit for a flag, set to 1 for a big string
         c = 31 bits for half the capacity. This is the size of the buffer
             pointed to by data, and which contains the actual characters of
             the string.
         size = 32 bits for the size of the string, i.e. the number of
             significant characters in the buffer.
         data = a pointer (32 or 64 bits depending on architecture)
         first = 32 bits, see the handling of substrings below
         pad = 32 bits on a 64 bits system, 0 otherwise.
             This is because of alignment issues.

So in the same amount of memory (24 bytes), we can either store a small string
of 23 characters or less with no memory allocations, or a big string that
requires allocation. In a typical application, most strings are smaller than 23
bytes, so we are saving very significant time here.

This representation has to work on both 32 bits systems and 64 bits systems, so
we have careful representation clauses to take this into account.  It also
needs to work on both big-endian and little-endian systems. Thanks to Ada's
representation clauses, this one in fact relatively easy to achieve (well,
okay, after trying a few different approaches to emulate what's done in C++,
and that did not work elegantly). In fact, emulating via bit-shift operations
ended up with code that was less efficient than letting the compiler do it
automatically because of our representation clauses.

Character types
===============

Applications should be able to handle the whole set of Unicode characters. In
Ada, these are represented as the Wide_Character type, rather than Character,
and stored on 2 bytes rather than 1. Of course, for a lot of applications it
would be wasting memory to always store 2 bytes per character, so we want to
give flexibility to users here.

So the package GNATCOLL.Strings_Impl is a generic. It has several formal
parameters, among which:

   * Character_Type is the type used to represent each character. Typically,
     it will be Character, Wide_Character, or even possibly
     Wide_Wide_Character. It could really be any scalar type, so for instance
     we could use this package to represent DNA with its 4-valued nucleobases.

   * Character_String is an array of these characters, as would be
     represented in Ada. It will typically be a String or a Wide_String. This
     type is used to make this package work with the rest of the Ada world.

Note about Unicode: we could also always use a Character, and use UTF-8
encoding internally. But this makes all operations (from taking the length to
moving the next character) slower, and more fragile. We must make sure not to
cut a string in the middle of a multi-byte sequence. Instead, we manipulate a
string of code points (in terms of Unicode). A similar choice is made in Ada
(String vs Wide_String), Python and C++.

Configuring the size of small strings
=====================================

The above is what is done for most C++ implementations nowadays.  The maximum
23 characters we mentioned for a small string depends in fact on several
criteria, which impact the actual maximum size of a small string:

   * on 32 bits system, the size of the big string is 16 bytes, so the maximum
     size of a small string is 15 bytes.
   * on 64 bits system, the size of the big string is 24 bytes, so the maximum
     size of a small string is 23 bytes.
   * If using a Character as the character type, the above are the actual
     number of characters in the string. But if you are using a
     Wide_Character, this is double the maximum length of the string, so a
     small string is either 7 characters or 11 characters long.

This is often a reasonable number, and given that applications mostly use small
strings, we are already saving a lot of allocations. However, in some cases we
know that the typical length of strings in a particular context is different.
For instance, GNATCOLL.Traces builds messages to output in the log file. Such
messages will typically be at most 100 characters, although they can of course
be much larger sometimes.

We have added one more formal parameter to GNATCOLL.Strings_Impl to control the
maximum size of small strings. If for instance we decide that a "small" string
is anywhere from 1 to 100 characters long (i.e. we do not want to allocate
memory for those strings), it can be done via this parameter.

Of course, in such cases the size of the string itself becomes much larger.
In this example it would be 101 bytes long, rather than the 24 bytes.  Although
we are saving on memory allocations, we are also spending more time copying
data when the string is passed around, so you'll need to measure the
performance here.

The maximum size for the small string is 127 bytes however, because this size
and the 1-bit flag need to fit in 1 bytes in the representation clauses we
showed above. We tried to make this more configurable, but this makes things
significantly more complex between little-endian and big-endian systems, and
having large "small" strings would not make much sense in terms of performance
anyway.

Typical C++ implementations do not make this small size configurable.

Task safety
===========

Just like unbounded strings, the strings in this package are not thread safe.
This means that you cannot access the same string (read or write) from two
different threads without somehow protecting the access via a protected type,
locks,...

In practice, sharing strings would rarely be done, so if the package itself
was doing its own locking we would end up with very bad performance in all
cases, for a few cases where it might prove useful.

As we'll discuss below, it is possible to use two different strings that
actually share the same internal buffer, from two different threads. Since this
is an implementation detail, this package takes care of guaranteeing the
integrity of the shared data in such a case.

Copy on write
=============

There is one more formal parameter, to configure whether this package should
use copy-on-write or not. When copy on write is enabled, you can have multiple
strings that internally share the same buffer of characters. This means that
assigning a string to another one becomes a reasonably fast operation (copy a
pointer and increment a refcount). Whenever the string is modified, a copy of
the buffer is done so that other copies of the same string are not impacted.

But in fact, there is one drawback with this scheme: we need reference counting
to know when we can free the shared data, or when we need to make a copy of it.
This reference counting must be thread safe, since users might be using two
different strings from two different threads, but they share data internally.

Thus the reference counting is done via atomic operations, which have some
impact on performance. Since multiple threads try to access the same memory
addresses, this is also a source of contention in multi-threaded applications.

For this reason, the current C++ standard prevents the use of copy-on-write
for strings.

In our case, we chose to make this configurable in the generic, so that users
can decide whether to pay the cost of the atomic operations, but save on the
number of memory allocations and copy of the characters.  Sometimes it is
better to share the data, sometimes to systematically copy it.
Again, actual measurements of the performance are needed for your specific
application.

Growth strategy
===============

When the current size of the string becomes bigger than the available allocated
memory (for instance because you are appending characters), this package needs
to reallocate memory. There are plenty of strategies here, from allocating only
the exact amount of memory needed (which saves on memory usage, but is very bad
in terms of performance), to doubling the current size of the string until we
have enough space, as currently done in the GNAT unbounded strings
implementation.

The latter approach would therefore allocate space for two characters, then
for 4, then 8 and so on.

This package has a slightly different strategy. Remember that we only start
allocating memory past the size of small strings, so we will for instance first
allocate 24 bytes. When more memory is needed, we multiply this size by 1.5,
which some researchers have found to be a good comprise between waste of memory
and number of allocations. For very large strings, we always allocate multiples
of the memory page size (4096 bytes), since this is what the system will make
available anyway. So we will basically allocate the following: 24, 36, 54, 82,
122,...

An additional constraint is that we only ever allocate even number of bytes.
This is called the capacity of the string. In the layout of the big string,
as shown above, we store half that capacity, which saves one bit that we
use for the flag.

Substrings
==========

One other optimization performed by this package (which is not done for
unbounded strings or various C++ implementations) is to optimize substrings
when also using copy-on-write.

We simply store the index of the first character of the string within the
shared buffer, instead of always starting at the first.

From the user's point of view, this is an implementation detail. Strings
are always indexed from 1, and internally we convert to an actual position
in the buffer. This means that if we need to reallocate the buffer, for
instance when the string is modified, we transparently change the index
of the first character, but the indexes the user was using are still valid.

This results in very significant savings, as shown below in the timings
for Trim for instance. Also, we can do an operation like splitting a
string very efficiently.

For instance, the following code doesn't allocate any memory, beside
setting the initial value of the string. It parses a file containing
some "key=value" lines, with optional spaces, and possibly empty lines::

     declare
        S, Key, Value : XString;
        L             : XString_Array (1 .. 2);
        Last          : Natural;
     begin
        S.Set (".......");

        --  Get each line
        for Line in S.Split (ASCII.LF) loop

           --  Split into at most two substrings
           Line.Split ('=', Into => L, Last => Last);

           if Last = 2 then
              Key := L (1);
              Key.Trim;    --  Removing leading and trailing spaces

              Value := L (2);
              Value.Trim;

           end if;
        end loop;
     end;

API
===

This package provides a very extensive set of API that apply to `XString`,
please check the spec in :file:`gnatcoll-strings_impl.ads` for a fully
documented list.