From 68a0ef6a93c5d622e545593fdd4521519047824b Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Thu, 26 Mar 2026 10:48:54 +0000 Subject: [PATCH] perf: reduce allocations and redundant work in hot paths Performance improvements: - FileSystem.fs: Cache Lines property on RoslynSourceTextFile via lazy, avoiding repeated Seq.toArray + Array.map on every access. Replace ToString().GetBytes() in GetFileContent with chunked ISourceText.CopyTo to avoid materializing the entire source as an intermediate string. Uses UTF8Encoding(false) to avoid BOM, matching original behavior. - Lexer.fs: Replace Array.fold with a simple for-loop for parsing defines and lang version from args. Each call creates its own FSharpSourceTokenizer (they are lightweight); no shared mutable state across threads. - CompilerServiceInterface.fs: Move normalizePath into each match branch of SourceFilesTagged to avoid an extra List.map pass. Replace quadratic Array.append fold in processFSIArgs with ResizeArray for O(n) behavior. - AdaptiveServerState.fs: Cache SourceFilesTagged on LoadedProject via a Lazy field and factory method, avoiding repeated List.map + List.toArray on every access. Replace OTel tag 'source.text' (which boxed entire file contents as a string) with 'source.length' (just the int length). - AdaptiveFSharpLspServer.fs: Add rereadFile parameter to completion retry logic so the file is only re-read when the error indicates stale content (e.g. line lookup failure, trigger char mismatch), not on every retry. The 'empty completions' case skips re-read since only typecheck matters. --- .../CompilerServiceInterface.fs | 17 ++++++----- src/FsAutoComplete.Core/FileSystem.fs | 25 ++++++++++++++-- src/FsAutoComplete.Core/Lexer.fs | 23 ++++++++------ .../LspServers/AdaptiveFSharpLspServer.fs | 18 +++++++---- .../LspServers/AdaptiveServerState.fs | 30 +++++++++++-------- .../LspServers/AdaptiveServerState.fsi | 10 ++++++- 6 files changed, 85 insertions(+), 38 deletions(-) diff --git a/src/FsAutoComplete.Core/CompilerServiceInterface.fs b/src/FsAutoComplete.Core/CompilerServiceInterface.fs index f901b9feb..5eff676c8 100644 --- a/src/FsAutoComplete.Core/CompilerServiceInterface.fs +++ b/src/FsAutoComplete.Core/CompilerServiceInterface.fs @@ -56,9 +56,8 @@ type CompilerProjectOption = member x.SourceFilesTagged = match x with - | BackgroundCompiler(options) -> options.SourceFiles |> Array.toList - | TransparentCompiler(snapshot) -> snapshot.SourceFiles |> List.map (fun f -> f.FileName) - |> List.map Utils.normalizePath + | BackgroundCompiler(options) -> options.SourceFiles |> Array.map Utils.normalizePath |> Array.toList + | TransparentCompiler(snapshot) -> snapshot.SourceFiles |> List.map (fun f -> Utils.normalizePath f.FileName) member x.ReferencedProjectsPath = match x with @@ -189,12 +188,16 @@ type FSharpCompilerServiceChecker None let processFSIArgs args = - (([||], [||]), args) - ||> Array.fold (fun (args, files) arg -> + let argsOut = ResizeArray() + let filesOut = ResizeArray() + + for arg in args do match arg with | StartsWith "--use:" file - | StartsWith "--load:" file -> args, Array.append files [| file |] - | arg -> Array.append args [| arg |], files) + | StartsWith "--load:" file -> filesOut.Add(file) + | arg -> argsOut.Add(arg) + + argsOut.ToArray(), filesOut.ToArray() let (|Reference|_|) (opt: string) = if opt.StartsWith("-r:", StringComparison.Ordinal) then diff --git a/src/FsAutoComplete.Core/FileSystem.fs b/src/FsAutoComplete.Core/FileSystem.fs index 54ae88996..ef5eae939 100644 --- a/src/FsAutoComplete.Core/FileSystem.fs +++ b/src/FsAutoComplete.Core/FileSystem.fs @@ -169,6 +169,9 @@ module RoslynSourceText = type RoslynSourceTextFile(fileName: string, sourceText: SourceText) = + let cachedLines = + lazy (sourceText.Lines |> Seq.toArray |> Array.map (fun l -> l.ToString())) + let walk ( x: IFSACSourceText, @@ -250,8 +253,7 @@ module RoslynSourceText = member x.TotalRange: Range = (Range.mkRange (UMX.untag fileName) Position.pos0 ((x :> IFSACSourceText).LastFilePosition)) - member x.Lines: string array = - sourceText.Lines |> Seq.toArray |> Array.map (fun l -> l.ToString()) + member x.Lines: string array = cachedLines.Value member this.GetText(range: Range) : Result = range.ToRoslynTextSpan(sourceText) |> sourceText.GetSubText |> string |> Ok @@ -488,7 +490,24 @@ type FileSystem(actualFs: IFileSystem, tryFindFile: string -> Volatil >> Log.addContext "hash" (file.Source.GetHashCode()) ) - file.Source.ToString() |> System.Text.Encoding.UTF8.GetBytes) + // Write source text to bytes in chunks via CopyTo, avoiding a full intermediate string allocation + let source = file.Source :> FSharp.Compiler.Text.ISourceText + let length = source.Length + let ms = new MemoryStream() + let utf8NoBom = System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier = false) + use sw = new StreamWriter(ms, utf8NoBom, bufferSize = 4096, leaveOpen = true) + let chunkSize = 8192 + let charBuffer = Array.zeroCreate (min length chunkSize) + let mutable offset = 0 + + while offset < length do + let count = min (length - offset) charBuffer.Length + source.CopyTo(offset, charBuffer, 0, count) + sw.Write(charBuffer, 0, count) + offset <- offset + count + + sw.Flush() + ms.ToArray()) /// translation of the BCL's Windows logic for Path.IsPathRooted. /// diff --git a/src/FsAutoComplete.Core/Lexer.fs b/src/FsAutoComplete.Core/Lexer.fs index 803dc487c..e16516806 100644 --- a/src/FsAutoComplete.Core/Lexer.fs +++ b/src/FsAutoComplete.Core/Lexer.fs @@ -59,17 +59,22 @@ module Lexer = /// Return all tokens of current line let tokenizeLine (args: string[]) lineStr = let defines, langVersion = - ((ResizeArray(), None), args) - ||> Array.fold (fun (defines, langVersion) arg -> - match arg with - | Define d -> - defines.Add(d) - defines, langVersion - | LangVersion v -> defines, Some(v) - | _ -> defines, langVersion) + if args.Length = 0 then + [], None + else + let defs = ResizeArray() + let mutable lang = None + + for arg in args do + match arg with + | Define d -> defs.Add(d) + | LangVersion v -> lang <- Some(v) + | _ -> () + + Seq.toList defs, lang let sourceTokenizer = - FSharpSourceTokenizer(Seq.toList defines, Some "/tmp.fsx", langVersion, None) + FSharpSourceTokenizer(defines, Some "/tmp.fsx", langVersion, None) let lineTokenizer = sourceTokenizer.CreateLineTokenizer lineStr diff --git a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs index 0b3e55043..eb1725752 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveFSharpLspServer.fs @@ -763,10 +763,14 @@ type AdaptiveFSharpLspServer | Error e -> return Error e } - let getCompletions forceGetTypeCheckResultsStale = + let getCompletions forceGetTypeCheckResultsStale rereadFile = asyncResult { - let! volatileFile = state.GetOpenFileOrRead filePath + let! volatileFile = + if rereadFile then + state.GetOpenFileOrRead filePath + else + async { return Ok volatileFile } let! lineStr = volatileFile.Source @@ -815,8 +819,12 @@ type AdaptiveFSharpLspServer match e with | "Should not have empty completions" -> // If we don't get any completions, assume we need to wait for a full typecheck - getCompletions state.GetOpenFileTypeCheckResults - | _ -> getCompletions state.GetOpenFileTypeCheckResultsCached + // No need to re-read the file — only the typecheck results matter + getCompletions state.GetOpenFileTypeCheckResults false + | "TextDocumentCompletion was sent before TextDocumentDidChange" -> + // File content is stale, re-read on next attempt + getCompletions state.GetOpenFileTypeCheckResultsCached true + | _ -> getCompletions state.GetOpenFileTypeCheckResultsCached true let getCodeToInsert (d: DeclarationListItem) = match d.NamespaceToOpen with @@ -850,7 +858,7 @@ type AdaptiveFSharpLspServer (TimeSpan.FromMilliseconds(15.)) 100 handleError - (getCompletions state.GetOpenFileTypeCheckResultsCached) + (getCompletions state.GetOpenFileTypeCheckResultsCached false) |> AsyncResult.ofStringErr with | None -> return! LspResult.success (None) diff --git a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs index ddebb11f0..13f218f61 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs @@ -121,7 +121,8 @@ type AdaptiveWorkspaceChosen = type LoadedProject = { ProjectOptions: Types.ProjectOptions FSharpProjectCompilerOptions: aval - LanguageVersion: LanguageVersionShim } + LanguageVersion: LanguageVersionShim + _sourceFilesTagged: Lazy array> } interface IEquatable with member x.Equals(other) = x.ProjectOptions = other.ProjectOptions @@ -133,11 +134,17 @@ type LoadedProject = | :? LoadedProject as other -> (x :> IEquatable<_>).Equals other | _ -> false - member x.SourceFilesTagged = - x.ProjectOptions.SourceFiles |> List.map Utils.normalizePath |> List.toArray + member x.SourceFilesTagged = x._sourceFilesTagged.Value member x.ProjectFileName = x.ProjectOptions.ProjectFileName + static member Create(projectOptions, fsharpProjectCompilerOptions, languageVersion) = + { ProjectOptions = projectOptions + FSharpProjectCompilerOptions = fsharpProjectCompilerOptions + LanguageVersion = languageVersion + _sourceFilesTagged = + lazy (projectOptions.SourceFiles |> List.map Utils.normalizePath |> List.toArray) } + /// The reality is a file can be in multiple projects /// This is extracted to make it easier to do some type of customized select in the future type IFindProject = @@ -1211,9 +1218,10 @@ type AdaptiveState let createSnapshots projectOptions = Snapshots.createSnapshots openFilesWithChanges (AVal.constant sourceTextFactory) (AMap.ofHashMap projectOptions) |> AMap.map (fun _ (proj, snap) -> - { ProjectOptions = proj - FSharpProjectCompilerOptions = snap |> AVal.map CompilerProjectOption.TransparentCompiler - LanguageVersion = LanguageVersionShim.fromOtherOptions proj.OtherOptions }) + LoadedProject.Create( + proj, + snap |> AVal.map CompilerProjectOption.TransparentCompiler, + LanguageVersionShim.fromOtherOptions proj.OtherOptions)) let createOptions projectOptions = let projectOptions = HashMap.toValueList projectOptions @@ -1233,9 +1241,7 @@ type AdaptiveState |> CompilerProjectOption.BackgroundCompiler Utils.normalizePath projectOption.ProjectFileName, - { FSharpProjectCompilerOptions = AVal.constant fso - LanguageVersion = langversion - ProjectOptions = projectOption }) + LoadedProject.Create(projectOption, AVal.constant fso, langversion)) |> AMap.ofList let loadedProjects = @@ -1589,9 +1595,7 @@ type AdaptiveState } return - { FSharpProjectCompilerOptions = opts |> AVal.constant - LanguageVersion = LanguageVersionShim.fromOtherOptions opts.OtherOptions - ProjectOptions = projectOptions } + LoadedProject.Create(projectOptions, opts |> AVal.constant, LanguageVersionShim.fromOtherOptions opts.OtherOptions) |> List.singleton with e -> @@ -1774,7 +1778,7 @@ type AdaptiveState let tags = [ SemanticConventions.fsac_sourceCodePath, box (UMX.untag file.Source.FileName) SemanticConventions.projectFilePath, box (options.ProjectFileName) - "source.text", box (file.Source.String) + "source.length", box (file.Source.Length) "source.version", box (file.Version) ] diff --git a/src/FsAutoComplete/LspServers/AdaptiveServerState.fsi b/src/FsAutoComplete/LspServers/AdaptiveServerState.fsi index 8fecbcfd1..9671c4f36 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fsi +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fsi @@ -32,13 +32,21 @@ type AdaptiveWorkspaceChosen = type LoadedProject = { ProjectOptions: Types.ProjectOptions FSharpProjectCompilerOptions: aval - LanguageVersion: LanguageVersionShim } + LanguageVersion: LanguageVersionShim + _sourceFilesTagged: Lazy array> } interface IEquatable override GetHashCode: unit -> int override Equals: other: obj -> bool + member SourceFilesTagged: string array member ProjectFileName: string + static member Create: + projectOptions: Types.ProjectOptions * + fsharpProjectCompilerOptions: aval * + languageVersion: LanguageVersionShim -> + LoadedProject + type AdaptiveState = new: lspClient: FSharpLspClient *