Skip to content

Commit ac73578

Browse files
tsnobipclaude
andauthored
Read build files once and publish outputs from memory in OCaml rewatch (#9170)
* Read each build file once per OCaml rewatch build Why: Every in-process compile read the interfaces it uses from disk again, above all the runtime's, and unmarshalled them again, and finding one took a failed `open` in each load-path directory before the one that has it. On macOS an `open` costs about 15 µs and gets slower when several threads call it at once, so a clean build of a 2000-module project spent a third of its CPU time in `open`. The compiler now reads and writes build files through `Build_file_store`. Rewatch enables it for the duration of each build: a file is read from disk at most once, a file written through the store is kept for later reads, and each domain keeps the interfaces it has unmarshalled until the file is written again. `bsc` leaves the store disabled and reads the disk as before. Writers render their output in memory first, so the bytes written are unchanged: every artifact of the fixtures below, `.cmt` files included, is byte-identical to master's. IO modified (file system calls of a clean build, 2000 modules, 8 domains): Before (text): open .cmi 70856 stat .cmj 30856 open .cmj 14856 open .res 10000 open .ast 8000 all calls 211523 After (text): open .cmi 5454 stat .cmj 1731 open .cmj 5722 open .res 8000 open .ast 6000 all calls 101462 Build modified (wall time, median of 5, Apple M4 Pro): Before (text): synthetic 2000 modules, clean 2.42 s tests/tests, clean 0.71 s After (text): synthetic 2000 modules, clean 2.11 s tests/tests, clean 0.67 s Review: Can a request read a stale file from the store? Within a build a file is read only after the request that writes it has finished, because rewatch orders requests by their dependencies, and writes go through the store. Requests spell paths relative to their working directory, such as `lib/bs/../ocaml/A.cmi`, so keys remove `.` and `..` lexically. The store starts every build empty, so it sees changes made between builds. Review: Is it safe to reuse an unmarshalled interface in later requests? Only on the domain that read it: copying a signature marks its types in place until the copy ends, which is why domains don't share them. A request that fails drops the domain's interfaces, in case it stopped with a type still marked, and every request resets the abbreviations it memoized, including those in cached interfaces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Read AST headers once, in parallel, in OCaml rewatch Why: Before anything compiles, a build reads the header of every AST twice on the main domain: the published copy in lib/ocaml, to know its source and dependencies, and again the one in lib/bs, to build the module graph. For a 2000-module project that was two thirds of a build after a one-file edit. The module graph now takes the dependencies of a module that wasn't parsed in this build from its published AST, which is a copy of the one in lib/bs, and those of a module parsed in this build from memory. The published ASTs are inventoried on all worker domains, and a header is read with one small read instead of through a channel that buffers 64 KiB of the AST. Build modified (wall time after a one-file edit, median of 5, Apple M4 Pro): Before (text): synthetic 2000-module project no-op build 0.139 s edit a leaf module 0.155 s After (text): synthetic 2000-module project no-op build 0.089 s edit a leaf module 0.104 s Review: When can the published AST's dependencies differ from the one in lib/bs? The published AST is a byte copy made when the module is parsed. A module whose source is newer than its published AST, or whose published AST is missing, is parsed again, and its dependencies are then read from the new AST in memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Publish outputs from memory, on the worker that made them, in OCaml rewatch Why: Publishing copies each output of the compiler into lib/ocaml, and each source into lib/bs and lib/ocaml, by reading back the files just written. After parsing, the main domain alone also copied every AST and source, one after another: in a clean build of a 2000-module project all workers sat idle for half a second. Copies now take their contents from `Build_file_store`, where the compiler left what it wrote, and keep the published interfaces and `.cmj` files for dependents to read; ASTs, typed trees and JavaScript leave the store once published. Each parse publishes its AST and source on the worker that ran it. The source copy that compiling publishes again is written only if it differs from the one the parse published. Every rewatch-side write or removal of a file updates the store. IO modified (file system calls of a clean build, 2000 modules, 8 domains): Before (text): all calls 99063 After (text): all calls 69125 Build modified (wall time, median of 5, Apple M4 Pro): Before (text): synthetic 2000 modules, clean 2.07 s After (text): synthetic 2000 modules, clean 1.80 s Review: Is a file a hook or PPX reads still on disk when it runs? Yes: writes through the store reach the disk before `write` returns; the store only spares reading them back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Test the build file store's failure paths Codecov flagged the store's failure handling as untested. The new cases check that an interface cut short reads as corrupted from memory as it does from disk, that a failed write keeps nothing for its path, and that a failed request makes its domain read its interfaces again while a successful one keeps them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Correct the clean-build figure in the build file store's changelog entry The entry said clean builds became about a quarter faster, as measured with the disk nearly full. Measured again with free space, a clean build of the synthetic 2000-module project takes 2.21 s on master and 1.47 s with this branch, about a third less. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Say in the OCaml rewatch guide that workers publish their outputs The guide still said the main domain publishes every artifact, which stopped being true when workers began publishing what they produce from memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Read an AST's header with one parser, in the compiler and OCaml rewatch Why: The header of a binary AST was parsed in three places: in `Binary_ast.read_ast_exn`, and twice in OCaml rewatch's `Ast_header`, once to find where the header ends in the first block read and once to read it. `read_ast_exn` also raised `Invalid_argument`, not `End_of_file`, for a header with a negative or impossible dependency length. `Binary_ast.read_header` finds where the dependency block and the source path end in the first bytes of an AST, or says they don't hold the whole header. `read_ast_exn` raises `End_of_file` for a file without one, and rewatch reads one block of a published AST, and all of it only when the header may be longer. Review: Does rewatch read any header differently? An AST with no newline after its source path now reads as damaged, so its module parses again; before, rewatch took the path to run to the end of the file. `write_ast` always writes the newline, so only a file cut short ends this way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Publish each parse from its task's result hook, in OCaml rewatch Why: Parses were published through an `on_success` hook added to `Compiler_process.task` and `run_requests`, which looked each parse up by its index, though `Process.map_result` already runs a function on the worker when a task finishes. Compile assets also regrouped the inventory by directory through a hashtable, though `Parallel.map` returns it in the order of the directories. `run_parses` maps each parse task's result to publish it, and `task` and `run_requests` are as they were before the build file store. `Compile_assets.create` takes each directory's entries in turn. The store says why its `enabled` and `generation` fields are read without its lock. Review: Is a parse still published on its worker, and does a failed publication still fail the parse? `Process.run_concurrent_task` applies the result hook on the worker, inside the handler that turns an exception into a failed task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Take a parsed module's dependencies from its new AST, in OCaml rewatch Why: The module graph reads a module's dependencies from the header of its published AST, which the build read before parsing. It told a module parsed in this build by its AST being held in the build file store, so a store that no longer held the AST, or no store, would have given the module the dependencies of its previous source, and the build would have skipped recompiles. `Module_graph.initialize` takes the paths that this build parsed successfully, which `Build_preparation` already knows, and reads their dependencies from their new AST in `lib/bs`. The store's copy of that AST only saves the disk read. Review: Does any other caller rely on the store to tell a fresh parse? The incremental path in `Build` reads every changed module's AST from `lib/bs` without the published headers, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Bound each domain's interface cache, in the compiler Why: Every compiler worker domain kept each interface it decoded for the rest of the build. A decoded interface takes a few times the size of its file, so a project with large interfaces could hold several copies of all of them, one per domain. A domain caches interfaces whose contents add up to at most 16 MB, and starts over when the next one would exceed that. The runtime's interfaces take 2 MB. An interface rewritten during the build replaces its entry and its size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Raise End_of_file for a truncated AST or .cmj, in the compiler Why: Reading from the build file store, an AST or `.cmj` cut short inside its marshalled value raised `Invalid_argument` from `Marshal.from_string`, though the AST reader is documented to raise `End_of_file` for a file a killed build cut short. A channel's `input_value` raised `Failure` there. `Ext_io.unmarshal` checks that the marshalled value ends within the contents, and raises `End_of_file` when it doesn't. The AST and `.cmj` readers use it, and read their file with `Ext_io.load_file` instead of their own copy of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Keep the build file store in step with removed files, in the compiler and OCaml rewatch Why: `Misc.remove_file`, which removes stale source maps and the `.cmi` of a failed save, deleted files without telling the build file store, so a later read in the same build would have returned the removed contents. Nothing reads those paths again today. An implementation compiled with `-bs-read-cmi` also read its interface's `.cmi` in `lib/bs` back into the store, which then kept it for the rest of the build. And when `Parallel.map` failed to start a domain, it raised without joining the domains it had started. `Misc.remove_file` forgets the path it removes, and publishing an implementation that has an interface evicts the `.cmi` that its compile read. `Parallel.map` shares the items among the domains that started. The store's interface no longer suggests `keep:false` for `.cmt` files, which rewatch copies out of the store. New tests cover a removed file, a file that exists but can't be read, several domains using the store at once, more spellings of one path, and AST headers that end at the edge of the first read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Test which files publishing keeps in the build file store, in OCaml rewatch Why: Publishing a module keeps its interface and `.cmj` in the store for its dependents, and evicts the rest so that a clean build's memory stays bounded. No test checked which files it kept, so an eviction that was dropped would only have shown as higher peak memory. The new test publishes a module and checks that the published `.cmi` and `.cmj` stay in the store, while the typed tree, the AST, the sources, the JavaScript output and every copy leave it. It also publishes an implementation that has an interface, whose compile reads its `.cmi` in `lib/bs` again, and checks that this `.cmi` leaves the store too. Each check fails without the eviction it covers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Publish parses that print warnings after their cleanup, in OCaml rewatch Why: A worker published each successful parse into `lib/ocaml` as soon as it finished, including a parse that printed warnings. The cleanup that removes such an AST, so that the next build parses the source again and repeats its warnings, is only registered once all the package's parses are done. A build interrupted between the two left the AST published and newer than its source, so the next build skipped the parse and never reported warnings such as the deprecated `(. ...)` syntax. The worker now publishes only parses that printed nothing. `Package_parse` publishes the others itself, after it registers their cleanup, as it did for every parse before the worker took over publication. A new test parses one clean source and one that warns, and checks that only the clean one is published. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> * Merge the two OCaml rewatch build I/O changelog entries into one The branch added one entry for the build file store and publication and another for the AST header scan. Each said it made builds about a third faster, without saying which builds, so they read as two similar claims. One entry now describes the three steps together, with the figures the pull request measured on the synthetic 2000-module project: a clean build takes 2.21 s on master and 1.47 s with the branch, a no-op build 0.154 s and 0.089 s, and a build after editing a leaf module 0.174 s and 0.100 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> --------- Signed-off-by: Paul Tsnobiladzé <2479216+tsnobip@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c10223e commit ac73578

38 files changed

Lines changed: 1609 additions & 293 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- OCaml rewatch: read each interface, `.cmj`, AST and source at most once per build, read the headers of existing ASTs once and in parallel, and publish outputs from memory instead of reading them back. On a large project, this cuts the time of a clean build by about a third, and of a no-op build or one that recompiles a single module by about 40%.

‎compiler/bsc/rescript_compiler_driver.ml‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,12 +569,28 @@ let start_request () =
569569
Predef.reset_for_request ();
570570
Env.reset_initial_for_request ()
571571

572+
(* Interfaces that this domain keeps for later requests must not keep this
573+
request's memoized abbreviations, so they are reset while the request's
574+
memo list is still current. A failed request may also have stopped with
575+
some of their types marked, so the domain drops them. *)
576+
let leave_cached_interfaces_clean action =
577+
match action () with
578+
| result ->
579+
Btype.cleanup_abbrev ();
580+
if result.exit_code <> 0 then Bs_cmi_load.discard_cached_cmis ();
581+
result
582+
| exception exn ->
583+
Btype.cleanup_abbrev ();
584+
Bs_cmi_load.discard_cached_cmis ();
585+
raise exn
586+
572587
let with_fresh_request_states ~cwd action =
573588
Predef.with_fresh (fun () ->
574589
Env.with_fresh_initial (fun () ->
575590
Ident.with_fresh (fun () ->
576591
Request_local.with_fresh (fun () ->
577-
Compiler_request_state.with_fresh ~cwd action))))
592+
Compiler_request_state.with_fresh ~cwd (fun () ->
593+
leave_cached_interfaces_clean action)))))
578594

579595
let run_argv ?run_external ~capture ~cwd argv =
580596
with_fresh_request_states ~cwd (fun () ->

‎compiler/core/bs_cmi_load.ml‎

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,83 @@
2222
* along with this program; if not, write to the Free Software
2323
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *)
2424

25+
(* Reads build files missing from [Build_file_store]. Open each file once and
26+
read that descriptor: on case-insensitive filesystems a lowercase alias can
27+
remain visible to [Sys.file_exists] briefly after its CMI is removed, and a
28+
vanished file must count as missing. *)
29+
let read_file path =
30+
match
31+
Unix.openfile path [Unix.O_RDONLY; Unix.O_CLOEXEC; Unix.O_SHARE_DELETE] 0
32+
with
33+
| descriptor ->
34+
let channel = Unix.in_channel_of_descr descriptor in
35+
Fun.protect
36+
~finally:(fun () -> close_in_noerr channel)
37+
(fun () -> Some (really_input_string channel (in_channel_length channel)))
38+
| exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None
39+
| exception Unix.Unix_error (error, _, _) ->
40+
raise (Sys_error (path ^ ": " ^ Unix.error_message error))
41+
42+
(* Interfaces read by earlier requests on this domain, by path, with the
43+
stored contents they were read from. Reading the same interfaces, above all
44+
the runtime's, is a sizable part of compiling a small module. The values
45+
can't be shared between domains: copying a signature marks its types in
46+
place for the duration of the copy. *)
47+
type cached_cmi = {contents: string; cmi: Cmi_format.cmi_infos}
48+
49+
type cmi_cache = {
50+
mutable generation: int;
51+
mutable size: int; (** The length of the cached contents. *)
52+
cmis: (string, cached_cmi) Hashtbl.t;
53+
}
54+
55+
(* A decoded interface takes a few times the size of its contents, and every
56+
domain keeps its own, so a domain that would cache more contents than this
57+
starts over. The runtime's interfaces take 2 MB. *)
58+
let cmi_cache_budget = 16 * 1024 * 1024
59+
60+
let cmi_cache_key =
61+
Domain.DLS.new_key (fun () ->
62+
{generation = -1; size = 0; cmis = Hashtbl.create 64})
63+
64+
let empty_cmi_cache cache ~generation =
65+
Hashtbl.reset cache.cmis;
66+
cache.size <- 0;
67+
cache.generation <- generation
68+
69+
let read_cached_cmi filename path contents =
70+
let cache = Domain.DLS.get cmi_cache_key in
71+
if cache.generation <> Build_file_store.generation () then
72+
empty_cmi_cache cache ~generation:(Build_file_store.generation ());
73+
match Hashtbl.find_opt cache.cmis path with
74+
| Some cached when cached.contents == contents -> cached.cmi
75+
| previous ->
76+
let cmi = Cmi_format.read_cmi_contents filename contents in
77+
Option.iter
78+
(fun previous ->
79+
cache.size <- cache.size - String.length previous.contents)
80+
previous;
81+
if cache.size + String.length contents > cmi_cache_budget then
82+
empty_cmi_cache cache ~generation:cache.generation;
83+
Hashtbl.replace cache.cmis path {contents; cmi};
84+
cache.size <- cache.size + String.length contents;
85+
cmi
86+
87+
(* A request that fails can stop with a type of a cached interface still
88+
marked, so the domain reads its interfaces again. *)
89+
let discard_cached_cmis () =
90+
empty_cmi_cache (Domain.DLS.get cmi_cache_key) ~generation:(-1)
91+
2592
let load_cmi ~unit_name : Env.Persistent_signature.t option =
26-
(* On case-insensitive filesystems a lowercase alias can remain visible to
27-
[Sys.file_exists] briefly after its CMI is removed. Open each candidate
28-
once and parse that descriptor, so a vanished CMI is a missing module. *)
2993
Misc.find_map_in_path_uncap (Config.get_load_path ()) (unit_name ^ ".cmi")
3094
(fun filename ->
3195
let path = Compiler_request_state.resolve_path filename in
32-
match Unix.openfile path [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 with
33-
| descriptor ->
34-
let channel = Unix.in_channel_of_descr descriptor in
96+
match Build_file_store.read path with
97+
| None -> None
98+
| Some contents ->
3599
let cmi =
36-
Fun.protect
37-
(fun () -> Cmi_format.read_cmi_channel filename channel)
38-
~finally:(fun () -> close_in_noerr channel)
100+
if Build_file_store.enabled () then
101+
read_cached_cmi filename path contents
102+
else Cmi_format.read_cmi_contents filename contents
39103
in
40-
Some Env.Persistent_signature.{filename; cmi}
41-
| exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None
42-
| exception Unix.Unix_error (error, _, _) ->
43-
raise (Sys_error (path ^ ": " ^ Unix.error_message error)))
104+
Some Env.Persistent_signature.{filename; cmi})

‎compiler/core/bs_conditional_initial.ml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
let setup_env () =
2626
Env.Persistent_signature.load := Bs_cmi_load.load_cmi;
27+
Build_file_store.set_disk_reader Bs_cmi_load.read_file;
2728
Matching.make_test_sequence_variant_constant :=
2829
Polyvar_pattern_match.make_test_sequence_variant_constant;
2930
Matching.call_switcher_variant_constant :=

‎compiler/core/js_cmj_format.ml‎

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -69,26 +69,15 @@ let make ~(values : cmj_value Map_string.t) ~effect_ ~package_spec ~case : t =
6969
}
7070

7171
(* Serialization .. *)
72-
(* Channels are closed also when decoding fails: the compiler can run inside a
73-
long-lived build process, which would otherwise keep the file open. *)
7472
let from_file name : t =
75-
let ic = open_in_bin (Compiler_request_state.resolve_path name) in
76-
Fun.protect
77-
~finally:(fun () -> close_in_noerr ic)
78-
(fun () ->
79-
let _digest = Digest.input ic in
80-
(input_value ic : t))
73+
Ext_io.unmarshal (Ext_io.load_file name) Ext_digest.length
8174

8275
let for_sure_not_changed (name : string) (header : string) =
83-
if Sys.file_exists (Compiler_request_state.resolve_path name) then
84-
let ic = open_in_bin (Compiler_request_state.resolve_path name) in
85-
Fun.protect
86-
~finally:(fun () -> close_in_noerr ic)
87-
(fun () ->
88-
match really_input_string ic Ext_digest.length with
89-
| holder -> holder = header
90-
| exception End_of_file -> false)
91-
else false
76+
match Build_file_store.read (Compiler_request_state.resolve_path name) with
77+
| Some contents ->
78+
String.length contents >= Ext_digest.length
79+
&& String.sub contents 0 Ext_digest.length = header
80+
| None -> false
9281

9382
(* This may cause some build system always rebuild
9483
maybe should not be turned on by default
@@ -98,13 +87,9 @@ let to_file name ~check_exists (v : t) =
9887
let cur_digest = Digest.string s in
9988
let header = cur_digest in
10089
if not (check_exists && for_sure_not_changed name header) then
101-
let oc = open_out_bin (Compiler_request_state.resolve_path name) in
102-
Fun.protect
103-
~finally:(fun () -> close_out_noerr oc)
104-
(fun () ->
105-
output_string oc header;
106-
output_string oc s;
107-
close_out oc)
90+
Build_file_store.write
91+
(Compiler_request_state.resolve_path name)
92+
(header ^ s)
10893

10994
let key_comp a b = Ext_string.compare a b.name
11095

‎compiler/core/lam_compile_main.ml‎

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -453,26 +453,42 @@ let remove_stale_source_map ?(remove_stale_map = true) target_file =
453453
if remove_stale_map && not !((Clflags.current ()).dont_write_files) then
454454
Misc.remove_file (target_file ^ ".map")
455455

456+
(* Where a program is printed: the request's stdout, or a buffer that becomes
457+
the output file. *)
458+
type sink = {printer: Ext_pp.t; add_string: string -> unit}
459+
460+
let channel_sink ~track_positions channel =
461+
{
462+
printer = Ext_pp.from_channel ~track_positions channel;
463+
add_string = output_string channel;
464+
}
465+
466+
let buffer_sink ~track_positions buffer =
467+
{
468+
printer = Ext_pp.from_buffer ~track_positions buffer;
469+
add_string = Buffer.add_string buffer;
470+
}
471+
456472
let dump_deps_program_with_source_map ?(remove_stale_map = true) ~target_file
457-
~output_prefix module_system lambda_output chan =
473+
~output_prefix module_system lambda_output sink =
458474
let builder =
459475
Js_source_map.make ~generated_file:target_file
460476
~source_root:!((Js_config.current ()).source_map_root)
461477
~sources_content:!((Js_config.current ()).source_map_sources_content)
462478
in
463479
Js_source_map.with_builder builder (fun () ->
464480
Js_dump_program.pp_deps_program ~output_prefix module_system lambda_output
465-
(Ext_pp.from_channel ~track_positions:true chan));
481+
sink.printer);
466482
match !((Js_config.current ()).source_map) with
467483
| Linked ->
468484
let json = Js_source_map.json builder in
469-
output_string chan
485+
sink.add_string
470486
(Js_source_map.linked_comment ~map_file:(target_file ^ ".map"));
471487
Ext_io.write_file (target_file ^ ".map") json
472488
| Hidden ->
473489
Ext_io.write_file (target_file ^ ".map") (Js_source_map.json builder)
474490
| Inline ->
475-
output_string chan
491+
sink.add_string
476492
(Js_source_map.inline_comment ~json:(Js_source_map.json builder));
477493
remove_stale_source_map ~remove_stale_map target_file
478494
| No_source_map -> ()
@@ -490,7 +506,8 @@ let lambda_as_module (lambda_output : J.deps_program) (output_prefix : string) :
490506
in
491507
dump_deps_program_with_source_map ~remove_stale_map:false ~target_file
492508
~output_prefix Commonjs lambda_output
493-
(Compiler_request_output.stdout_channel ())
509+
(channel_sink ~track_positions:true
510+
(Compiler_request_output.stdout_channel ()))
494511
| _ ->
495512
Js_dump_program.dump_deps_program ~output_prefix Commonjs lambda_output
496513
(Compiler_request_output.stdout_channel ())
@@ -505,18 +522,19 @@ let lambda_as_module (lambda_output : J.deps_program) (output_prefix : string) :
505522
Ext_path.package_dir () // path
506523
// basename (* #913 only generate little-case js file *)
507524
in
508-
let output_chan chan =
509-
match !((Js_config.current ()).source_map) with
525+
if not !((Clflags.current ()).dont_write_files) then (
526+
let buffer = Buffer.create 4096 in
527+
(match !((Js_config.current ()).source_map) with
510528
| No_source_map ->
511-
Js_dump_program.dump_deps_program ~output_prefix module_system
512-
lambda_output chan;
529+
Js_dump_program.pp_deps_program ~output_prefix module_system
530+
lambda_output
531+
(Ext_pp.from_buffer buffer);
513532
remove_stale_source_map target_file
514533
| Linked | Inline | Hidden ->
515534
dump_deps_program_with_source_map ~target_file ~output_prefix
516-
module_system lambda_output chan
517-
in
518-
if not !((Clflags.current ()).dont_write_files) then
519-
Ext_pervasives.with_file_as_chan target_file output_chan)
535+
module_system lambda_output
536+
(buffer_sink ~track_positions:true buffer));
537+
Build_file_store.write target_file (Buffer.contents buffer)))
520538

521539
(* We can use {!Env.current_unit = "Pervasives"} to tell if it is some specific module,
522540
We need handle some definitions in standard libraries in a special way, most are io specific,

‎compiler/depends/binary_ast.ml‎

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,42 @@ type 'a kind = 'a Ml_binary.kind =
2828
| Ml : Parsetree.structure kind
2929
| Mli : Parsetree.signature kind
3030

31-
let read_ast_exn (type t) ~fname (_ : t kind) : t =
32-
let ic = open_in_bin (Compiler_request_state.resolve_path fname) in
33-
Fun.protect
34-
~finally:(fun () -> close_in_noerr ic)
35-
(fun () ->
36-
let dep_size = input_binary_int ic in
37-
seek_in ic (pos_in ic + dep_size);
38-
let sourcefile = input_line ic in
39-
Location.set_input_name sourcefile;
40-
input_value ic)
41-
4231
let magic_sep_char = '\n'
4332

33+
type header = {source_start: int; source_end: int}
34+
35+
let read_header ?length contents =
36+
let length = Option.value length ~default:(String.length contents) in
37+
if length < 4 then None
38+
else
39+
let source_start = 4 + Int32.to_int (String.get_int32_be contents 0) in
40+
if source_start < 4 || source_start > length then None
41+
else
42+
let rec source_end index =
43+
if index >= length then None
44+
else if contents.[index] = magic_sep_char then
45+
Some {source_start; source_end = index}
46+
else source_end (index + 1)
47+
in
48+
source_end source_start
49+
50+
let header_dependencies contents header =
51+
String.sub contents 4 (header.source_start - 4)
52+
|> String.split_on_char magic_sep_char
53+
|> List.filter (fun name -> name <> "")
54+
55+
let header_source contents header =
56+
String.sub contents header.source_start
57+
(header.source_end - header.source_start)
58+
59+
let read_ast_exn (type t) ~fname (_ : t kind) : t =
60+
let contents = Ext_io.load_file fname in
61+
match read_header contents with
62+
| Some header ->
63+
Location.set_input_name (header_source contents header);
64+
Ext_io.unmarshal contents (header.source_end + 1)
65+
| None -> raise End_of_file
66+
4467
(*
4568
Reasons that we don't [output_value] the set:
4669
1. for performance , easy skipping and calcuate the length
@@ -57,13 +80,15 @@ let write_ast (type t) ~(sourcefile : string) ~output (kind : t kind) (pt : t) :
5780
(* filter *predef* *)
5881
Ext_buffer.add_string_char buf s magic_sep_char)
5982
output_set;
60-
let oc = open_out_bin (Compiler_request_state.resolve_path output) in
61-
Fun.protect
62-
~finally:(fun () -> close_out_noerr oc)
63-
(fun () ->
64-
output_binary_int oc (Ext_buffer.length buf);
65-
Ext_buffer.output_buffer oc buf;
66-
output_string oc sourcefile;
67-
output_char oc '\n';
68-
output_value oc pt;
69-
close_out oc)
83+
let length = Bytes.create 4 in
84+
Bytes.set_int32_be length 0 (Int32.of_int (Ext_buffer.length buf));
85+
Build_file_store.write
86+
(Compiler_request_state.resolve_path output)
87+
(String.concat ""
88+
[
89+
Bytes.unsafe_to_string length;
90+
Ext_buffer.contents buf;
91+
sourcefile;
92+
"\n";
93+
Marshal.to_string pt [];
94+
])

‎compiler/depends/binary_ast.mli‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
type _ kind = Ml : Parsetree.structure kind | Mli : Parsetree.signature kind
2626

2727
val read_ast_exn : fname:string -> 'a kind -> 'a
28+
(** Raises [End_of_file] when the file ends before its header or its AST does,
29+
as when a killed build cut it short. *)
2830

2931
val write_ast : sourcefile:string -> output:string -> 'a kind -> 'a -> unit
3032
(** [write_ast ~sourcefile ~output kind ast] writes to [output]:
@@ -33,5 +35,22 @@ val write_ast : sourcefile:string -> output:string -> 'a kind -> 'a -> unit
3335
(predefined names excluded), each followed by a newline;
3436
- [sourcefile] and a newline;
3537
- the marshalled [ast].
36-
[read_ast_exn] skips the dependency block; rewatch reads it in
37-
[get_dep_modules] (rewatch/src/build/deps.rs). *)
38+
[read_ast_exn] skips the dependency block; OCaml rewatch reads it with
39+
{!read_header}, and Rust rewatch in [get_dep_modules]
40+
(rewatch/src/build/deps.rs). *)
41+
42+
type header = {source_start: int; source_end: int}
43+
(** Where the parts of an AST's header end: the dependency block takes the
44+
bytes from 4 to [source_start], the source path those up to
45+
[source_end], a newline, and the marshalled AST follows. *)
46+
47+
val read_header : ?length:int -> string -> header option
48+
(** [read_header ?length contents] reads the header of an AST whose first
49+
[length] bytes, all of them by default, are those of [contents]. It is
50+
[None] when they end before the header does, or give an impossible length
51+
for the dependency block. *)
52+
53+
val header_dependencies : string -> header -> string list
54+
(** The module names in the dependency block. *)
55+
56+
val header_source : string -> header -> string

0 commit comments

Comments
 (0)